Compare commits

...
101 Commits
Author SHA1 Message Date
Hafid Nur 6d3e2470c8 fix(landing): remove unnecessary line after footer
Nix Build & Deploy / detect (push) Canceled after 0s
Nix Build & Deploy / build (${{ fromJson(needs.detect.outputs.affected) }}) (push) Canceled after 0s
Nix Build & Deploy / deploy (push) Canceled after 0s
2026-04-30 15:45:40 +07:00
Asep Haryana SaputraandGitHub 48411d3a08 Dashboard users part 2 (#79)
* fix: add FigmaImage directory to .gitignore

* feat: enhance dashboard tests and add new components for rendering

* feat: add navigation functionality and UI enhancements to dashboard components

* feat: add article preview and mentor contact modals, implement article builder and FAQ page

* refactor: remove FAQ route and update settings layout for improved responsiveness

* feat: enhance two-step authentication UI and functionality with improved styles and user feedback

* feat: add mentoring detail and feedback modals, enhance mentoring page functionality and UI

* fix: handle JSON parse failure in edit profile modal error handling
2026-04-15 12:20:05 +07:00
Asep Haryana SaputraandGitHub 426b71f341 fix: update action versions in Nix build workflow (#78) 2026-04-13 14:28:10 +07:00
Maulana SodiqinandClaude Opus 4.6 06c69cb3c5 fix(backoffice): React 19 read-only property runtime errors
Menambahkan resolve.dedupe untuk react/react-dom/@tanstack/react-router/
@tanstack/react-query di vite.config.ts agar tidak ada duplicate instance
yang di-bundle dari Radix primitives.

Menghapus StrictMode dari main.tsx karena combining StrictMode dengan
recharts 3.x (yang bawa @reduxjs/toolkit + immer autoFreeze) + TanStack
Router 1.168 trigger "Cannot assign to read only property 'lanes' of
#<li>" dan "pendingBuiltLocation of #<zr>" saat navigate.

Juga perbaiki duplikasi block build: di config lama ada dua property
build (yang kedua menimpa yang pertama jadi rolldownOptions hilang).
Sekarang rolldownOptions code-splitting vendor aktif.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:57:57 +07:00
Maulana SodiqinandClaude Opus 4.6 81999aa044 chore: trigger CI rebuild after npmDepsHash fix
Commit fix hash sebelumnya (d83a6d9) hanya menyentuh flake.nix sehingga
nx affected tidak mendeteksi app mana pun → matrix build kosong →
redesign belum ter-deploy. Formatting whitespace di libs/ui/index.css
membuat libs/ui terdeteksi affected dan cascade ke backoffice, gacha,
dimentorin, hackathon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:26:40 +07:00
Maulana SodiqinandClaude Opus 4.6 d83a6d9ec7 fix(nix): update npmDepsHash after adding shadcn deps
package-lock.json berubah karena +20 radix-ui primitives, lucide-react,
cmdk, input-otp. Nix CI gagal karena hash npm deps tidak match. Hash
baru diambil dari output CI yang gagal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:15:25 +07:00
Maulana SodiqinandClaude Opus 4.6 63dfb7299f feat(backoffice): redesign backoffice UI dengan shadcn/ui
Menyeluruh merancang ulang apps/backoffice memakai pola shadcn/ui sambil
mempertahankan seluruh palette warna existing (primary/neutral/success/
info/warning/danger) dan typography Bai Jamjuree.

UI library (libs/ui):
- Atoms existing (Button, Input, Label, Textarea, Card, Dialog, Drawer,
  Form, Select, Toggle) dirombak ke pola shadcn, API dipertahankan agar
  sibling apps (gacha, dimentorin, hackathon, qrcampaign) tidak break
- Select atom beralih dari native ke Radix Select + NativeSelect fallback
- 18 atom baru: Badge, Avatar, Separator, Skeleton, Tabs, Tooltip,
  DropdownMenu, Popover, AlertDialog, Checkbox, RadioGroup, Switch,
  ScrollArea, Sheet, Sidebar, Breadcrumb, Table, + use-mobile hook
- DataTable, Filter, BackofficeWrapper, Pagination, Modal (alias Dialog)
  direstyle dengan token baru
- CSS variable layer shadcn ditambahkan (--color-primary, --color-sidebar-*,
  dll) dipetakan ke palette existing; tw-animate-css di-import untuk
  transisi

Backoffice shell:
- Sidebar bersarang SidebarProvider + SidebarInset, built-in mobile Sheet,
  user DropdownMenu (Avatar + logout), ikon lucide-react

Pages redesign (19 list/dashboard/settings):
- accounts, cms-events, cms-testimonials, dashboard, dashboard-dimentorin
  (dengan recharts di Card), feedback-review-dimentorin, gacha-roll,
  hackathon-dashboard, hackathon-submissions, hackathon-teams,
  hackathon-users, permissions, prizes, roadmap-dimentorin, roles,
  session-dimentorin, settings-dimentorin (Tabs), transactions,
  users-dimentorin (Tabs mentor/mentee)
- Pola unified: BackofficeWrapper > Card (CardHeader toolbar + CardContent
  DataTable) + AlertDialog untuk delete confirm
- Form accounts_/$id diredesign; form pages lain tetap pakai
  ControlledInputField yang sekarang berbasis shadcn Input

Dependencies:
- +20 radix-ui primitives, lucide-react, cmdk, input-otp
- Select native patched di dimentorin (profile-sidebar, schedule step)
  untuk tetap kompatibel via NativeSelect alias

Verifikasi:
- nx build backoffice: sukses (2.33s, 4801 modules)
- nx build dimentorin/gacha/hackathon/qrcampaign: semua sukses (zero
  regresi dari rewrite libs/ui)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:01:47 +07:00
maulanasdqn 53079fe868 refactor: move backoffice sidebar from shared libs to app-local component 2026-04-12 00:11:04 +07:00
maulanasdqnandClaude Opus 4.6 2944a2b98a chore: remove dead code and unused dependencies
Deleted:
- libs/utils/src/react-router/ — old file-based routing system (replaced by TanStack Router)
- next-themes dependency (unused since Astro migration)
- react-router-dom from vite external config

Cleaned:
- Removed react-router barrel export from utils/index.ts
- Kept landing data files (still used by Astro components)
- Kept QueryProvider and ModalLoginProvider (still used by apps)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:37:28 +07:00
maulanasdqn 79f087e2fc fix: replace beforeLoad redirect with client-side navigate on backoffice index route 2026-04-11 23:24:59 +07:00
maulanasdqnandClaude Opus 4.6 d5b267332f fix: generate route trees and fix build errors for all apps
- Generated routeTree.gen.ts for all 6 apps (was empty placeholder)
- Fixed className backtick syntax in roles create/edit pages
- Fixed unescaped quote in dimentorin settings FAQ
- Route trees enable proper SPA navigation (no more full page reloads)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:53:17 +07:00
maulanasdqnandClaude Opus 4.6 8cb7b592eb fix: smooth client-side sidebar navigation with useNavigate
Replaced <a> tags with button + useNavigate() for instant SPA
navigation without full page reloads. Sidebar now:
- Uses TanStack Router useNavigate for all menu items
- Closes mobile menu on navigation (onClose callback)
- No more page flicker when switching between sections
- Logout redirects to /auth/login via navigate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:23:55 +07:00
maulanasdqnandClaude Opus 4.6 b2ed098067 feat: integrate dimentorin pages with real API, convert modals to pages
Roadmap: integrated with /v1/landing/cms/roadmap API (was dummy data)
- Created roadmap service layer (types, API, hooks)
- List page with search/filter, create/edit as dedicated pages

Session: converted modal detail to dedicated /session-dimentorin/$id page
Users: converted modal detail/delete to dedicated /users-dimentorin/$id page

Deleted all dimentorin modal components (roadmap, session, users).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:19:06 +07:00
maulanasdqnandClaude Opus 4.6 8749e83dad feat: convert all backoffice CRUD from modals to dedicated pages
Replaced modal-based create/edit/delete with dedicated TanStack Router
pages for 7 modules:

- Roles: /roles, /roles/create, /roles/$id
- Permissions: /permissions, /permissions/create, /permissions/$id
- CMS Events: /cms-events, /cms-events/create, /cms-events/$id
- CMS Testimonials: /cms-testimonials, /cms-testimonials/create, /cms-testimonials/$id
- Accounts: /accounts, /accounts/$id
- Gacha Dashboard: /dashboard, /dashboard/create, /dashboard/$id
- Gacha Rolls: /gacha-roll, /gacha-roll/create, /gacha-roll/$id

Each module now has:
- List page with DataTable + inline delete confirmation
- Dedicated create page with form + back navigation
- Dedicated edit page with pre-filled form + back navigation
- No more modals for CRUD operations

Deleted 21 modal component files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:02:57 +07:00
maulanasdqn 00b0997a9b chore: remove unused useSearch import from hackathon-users 2026-04-11 20:49:55 +07:00
maulanasdqn 05bdac6ee0 fix: auto-expand sidebar group containing current route on page load 2026-04-11 20:39:17 +07:00
maulanasdqn 5cbad2a996 fix: replace TanStack Router Link with plain <a> in shared sidebar to avoid route type issues 2026-04-11 18:29:41 +07:00
maulanasdqn e78002ff69 fix: handle nested data.data response from hackathon admin API 2026-04-11 18:11:07 +07:00
maulanasdqn b48f653a97 fix: replace all remaining react-router imports with @tanstack/react-router in shared UI and hackathon components 2026-04-11 17:54:04 +07:00
maulanasdqn 63da701b1b fix: resolve _hook import paths and signUpWithEmail type mismatch 2026-04-11 17:35:52 +07:00
maulanasdqn 7bef667e96 fix: import uploadUserFile from api/users instead of api/upload 2026-04-11 17:28:42 +07:00
maulanasdqn 82d1fae3d5 fix: resolve duplicate export errors in service lib (uploadUserFile, TLoginRequest, UserDetailResponseDto) 2026-04-11 17:24:00 +07:00
maulanasdqn 49d8419c1c fix: update npmDepsHash for TanStack Router deps 2026-04-11 17:11:25 +07:00
maulanasdqnandClaude Opus 4.6 4240e8eb51 feat: migrate all 5 Vite apps from react-router to TanStack Router
Migrated backoffice, hackathon, dimentorin, gacha, qrcampaign, and
infra from custom react-router file-based routing to TanStack Router
file-based routing following the tanstack-frontend-best-practice
convention.

Key changes per app:
- New routes/ directory with __root.tsx, _public.tsx, _authenticated.tsx
- Auth guards via beforeLoad (replaces old middleware.ts)
- createFileRoute pattern for all page components
- TanStackRouterVite plugin in vite.config for auto route generation
- _components/_hooks folders colocated with routes (ignored by router)
- routeTree.gen.ts auto-generated on dev/build

Convention:
- _public/* routes redirect to dashboard if authenticated
- _authenticated/* routes redirect to /auth/login if not authenticated
- $param for dynamic segments (was [param] in old convention)
- _layout suffix for pathless layout routes

Removed:
- Old src/app/ directories from all apps
- Old src/middleware.ts files
- Custom convertPagesToRoute utility (no longer needed)
- react-router dependency usage (kept in package.json for shared libs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:04:14 +07:00
maulanasdqnandClaude Opus 4.6 86d9e759a6 fix: route groups (public)/(protected) should be pathless layouts
The file-based router was converting (group) segments to 'group?'
optional parameters, which broke all SPA routes (404 on every page).
Now (group) segments map to empty path '' which creates pathless
layout wrapper routes in react-router, matching Next.js behavior.

e.g. (public)/auth/login/page.tsx → route path '/auth/login'
     (protected)/dashboard/page.tsx → route path '/dashboard'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:58:36 +07:00
maulanasdqnandClaude Opus 4.6 6b3faf2a21 fix: remove dev-only CSS links from index.html in all Vite apps
The <link rel="stylesheet" href="/src/..."> tags reference source
paths that only exist in dev mode. In production builds, Vite injects
CSS through the JS bundle. These broken links caused 404 errors and
unstyled pages in production.

Affected: backoffice, dimentorin, gacha, hackathon, infra

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:40:03 +07:00
maulanasdqnandClaude Opus 4.6 bcfac70e2a fix: remove all dark mode styles from shared UI components
Stripped dark: classes from Input, Textarea, Button, and InputField
components. These were causing black/dark backgrounds in modals and
forms across all apps (backoffice, gacha, dimentorin, etc).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:44:04 +07:00
maulanasdqn cb32146daa fix: update npmDepsHash after removing next.js 2026-04-11 10:17:28 +07:00
maulanasdqn b82b03a8a2 fix: remove @nx/next plugin references from nx.json 2026-04-11 10:14:20 +07:00
maulanasdqn 0e0ca9720a chore: update npmDepsHash after security fixes 2026-04-11 10:10:49 +07:00
maulanasdqnandClaude Opus 4.6 964dd009cc security: fix Dependabot alerts - remove next.js, update vulnerable deps
Resolved alerts:
- #158, #159: Removed next.js entirely (landing migrated to Astro)
- #157: Updated axios to 1.15.0 (NO_PROXY SSRF bypass)
- #154, #152, #150: Updated vite to 7.3.1+ (file read, path traversal, fs.deny bypass)
- #156, #155: lodash updated via audit fix
- #148: defu updated via audit fix
- #146: picomatch partially fixed (transitive dep still at 4.0.2 in some paths)
- #140: serialize-javascript updated via audit fix

Removed @nx/next as no longer needed after Astro migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:07:59 +07:00
maulanasdqn a7ec622925 fix: center navbar links in landing page header 2026-04-11 10:00:55 +07:00
maulanasdqn 931355e64e chore: force landing Astro deploy 2026-04-10 22:37:10 +07:00
maulanasdqn 58fbfcd0d8 chore: trigger landing deploy with updated infra config 2026-04-10 22:34:54 +07:00
maulanasdqn 063587aca9 fix: update landing project.json for Nx affected detection and correct build output 2026-04-10 22:27:00 +07:00
maulanasdqn 7576b2557d fix: update npmDepsHash for Astro migration 2026-04-10 22:14:47 +07:00
maulanasdqnandClaude Opus 4.6 95bf09c2fa feat: migrate landing page from Next.js to Astro
Replaces the heavy Next.js SSR app with Astro static site:
- Zero JS shipped for static pages (hero, community, CTA, footer)
- Server-side data fetching in Astro frontmatter (events, hackathon, testimonials)
- Only 4 React islands for interactive parts (mobile menu, roadmap voting, testimonials home, feature request)
- Google Fonts via <link> instead of next/font
- Nix build changed from Node.js systemd service to static nginx (mkStaticAppModule)
- npmDepsHash needs update (set to fake hash, CI will provide correct one)

Performance gains:
- No Node.js server process needed (was using ~10MB RAM)
- No Next.js runtime JS (~200KB+ saved)
- No framer-motion bundle (~130KB saved)
- Static HTML served directly by nginx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:10:52 +07:00
maulanasdqnandClaude Opus 4.6 0ade3c3e50 perf: optimize landing page - remove framer-motion, use Google Fonts
Performance improvements:
- Replaced framer-motion animations with CSS @keyframes in hero,
  testimonial, and community sections (saves ~130KB bundle)
- Switched from local Poppins/BaiJamjuree font files (750KB) to
  next/font/google with auto-subsetting
- Deleted 10 local .ttf font files
- Community section is now a server component (no 'use client')
- Removed scroll parallax effect from hero (was causing repaints)
- Hero image marked as priority for LCP optimization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:53:04 +07:00
maulanasdqnandClaude Opus 4.6 e945da4b7f feat: integrate landing with real APIs, remove auth pages
Landing page changes:
- Removed entire (auth) directory — landing doesn't need login/signup
- Removed auth buttons (Masuk/Daftar) from header navigation
- Hackathon page now fetches teams + winners from real API
- Roadmap page now fetches from /v1/landing/cms/roadmap API with
  real-time voting via POST /roadmap/vote/{id}
- Deleted dummy JSON files: events.json, testimonials.json, hackathons.json
- Kept static config: hero-content, hero-stats, navigations, socials

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:42:43 +07:00
maulanasdqnandClaude Opus 4.6 61b650bb25 feat: add react-hook-form + zod validation to all login forms
All login pages now use react-hook-form with zod resolver for
real-time validation:
- Email validated as proper email format on each keystroke
- Password required validation
- Red border + error message shown inline below invalid fields
- Submit button disabled until all fields are valid
- Input type="text" instead of type="email" to avoid browser tooltip

Also fixed:
- Gacha API trailing slash on /items/ and /credits/ causing 404s

Apps updated: hackathon, dimentorin, backoffice, qrcampaign, gacha

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:47:03 +07:00
maulanasdqnandClaude Opus 4.6 e61c003e07 fix: convert landing events/testimonials to client components
Server-side fetch fails during Nix build due to SSL cert issues in
the sandbox. Converted to client components with useEffect+fetch
so data loads at runtime instead of build time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:34:37 +07:00
maulanasdqn 8fff0df753 chore: force dimentorin redeploy 2026-04-10 15:10:47 +07:00
maulanasdqn 054f16053d chore: trigger deploy with updated infra flake inputs 2026-04-10 15:07:49 +07:00
maulanasdqnandClaude Opus 4.6 0193245551 fix: replace ugly Loading... text with spinner across all apps
- Updated default HydrateFallback in file-based-routing to show a
  centered blue spinner instead of bare "Loading..." text
- Added inline spinner to all index.html root divs so users see a
  spinner immediately while JS bundles load (instead of blank white)
- Affects: dimentorin, hackathon, backoffice, gacha, qrcampaign, infra

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:47:24 +07:00
maulanasdqnandClaude Opus 4.6 7f5948724b fix: remove dark mode from all login UIs, redesign gacha login modal
- Stripped all dark: classes from hackathon, dimentorin, backoffice,
  and qrcampaign login pages — light mode only
- Rewrote gacha login modal to match the unified clean design:
  clean typography, native inputs with eye toggle, proper spacing,
  removed old IMPHNEN logo header and Indonesian text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:44:32 +07:00
maulanasdqnandClaude Opus 4.6 2cba806cd5 feat: integrate landing page with real API and add CMS to backoffice
Landing page:
- Events page now fetches from /v1/landing/cms/events (was hardcoded JSON)
- Testimonials section and page fetch from /v1/landing/cms/testimonials
- Removed dummy data, uses ISR with 60s revalidation
- Replaced thumbnail images with text-based cards (API has no thumbnails)
- Avatar initials for testimonials instead of placeholder images

Backoffice CMS:
- Added Events management page (/cms-events) with full CRUD
- Added Testimonials management page (/cms-testimonials) with full CRUD
- Both follow existing backoffice patterns (DataTable, modals, search, pagination)
- Added CMS section to sidebar navigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:42:24 +07:00
maulanasdqnandClaude Opus 4.6 414e4d19b8 feat: add dimentorin dashboard and settings pages (#42, #43, #54)
Dashboard (/dashboard):
- Welcome header with user name
- Stats cards: upcoming sessions, completed, role (mentor/mentee)
- Mentor registration CTA for non-mentors
- Upcoming sessions list with status badges
- Profile card with avatar and gradient header

Settings (/dashboard/settings):
- Account Details: displays user info and role
- Privacy & Security: 2FA (coming soon), change password, active sessions
- Preferences: email notifications, language, theme
- FAQ & Support: expandable FAQ accordion with contact info

Dashboard Layout:
- Top nav with Dimentorin branding
- Navigation links: Dashboard, Settings
- Auth guard redirects to login if not authenticated
- Logout button

Closes #42, #43, #54

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:31:49 +07:00
maulanasdqnandClaude Opus 4.6 7268403ca4 fix: add image upload size limits for hackathon (#63)
- useUploadFile: add 5MB limit + image type validation for team
  logo/banner uploads (was unrestricted)
- useUploadAvatar: tighten limit from 5MB to 2MB per issue request
- Submit page: switch from useUploadFile to useUploadSubmission
  which has proper 50MB limit + broader file type support

Size limits:
  - Profile avatar: 2MB (JPEG, PNG, WebP, GIF)
  - Team logo/banner: 5MB (JPEG, PNG, WebP, GIF)
  - Team files: 20MB (images + PDF)
  - Submissions: 50MB (images, PDF, ZIP, video)

Closes #63

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:24:18 +07:00
maulanasdqnandClaude Opus 4.6 651fd65ccb feat: unify login UI across all apps to match hackathon design
- Dimentorin: replaced anime-themed login with clean dark-mode design,
  removed Google login, uses shared useLogin hook
- Backoffice: replaced custom hook login with unified design, clean
  admin-only layout (no signup link)
- QR Campaign: added dark mode support to match hackathon style

All login pages now share consistent:
- Dark/light mode support
- Email + password fields with show/hide toggle
- GitHub OAuth button
- Error handling UI
- Same Tailwind class patterns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:18:04 +07:00
maulanasdqnandClaude Opus 4.6 4e9d110bb0 fix: set default API base URL to https://api.imphnen.dev
VITE_API_URL was never set in the Nix build, so getBaseURL() returned
empty string causing all API calls to hit the current origin (e.g.
hackathon.imphnen.dev) instead of api.imphnen.dev. This caused 405
errors on login since the frontend server doesn't handle POST.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:15:27 +07:00
maulanasdqnandClaude Opus 4.6 c6719b19e5 feat: align frontend types with unified /me API response
- Add THackathonProfile, TQrProfile, TMentorProfile, TSessionProfile
  and TUsersMeResponse types matching the backend unified /me endpoint
- getUserMe() now accepts optional include[] param for selective
  module loading (?include=hackathon,qr,mentor,sessions)
- useSessionQuery() accepts include[] to control which modules load
- Fix SessionUser storage to use optional fields matching backend
- Remove hardcoded fallback values in useLogin (birthdate, gender)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:31:32 +07:00
maulanasdqnandClaude Sonnet 4.6 53ad40f3fd feat: integrate all backend APIs into frontend apps
- Fix API service paths to use /v1/iam/, /v1/dimentorin/, /v1/gacha/, /v1/hackathon/ prefixes
- Add roles, permissions, events, testimonials, sessions API services and hooks
- Rewrite gacha service with full CRUD + roll/claim endpoints
- Replace all mock data in backoffice pages with real API calls (accounts, roles, permissions, gacha-roll, dashboard, sessions, users-dimentorin, feedback-review, settings)
- Wire dimentorin mentoring list to useMentorList with search + pagination
- Wire dimentorin mentor detail page to useMentorById, pass real data to all sections
- Wire appointment modal to useBookSession with controlled schedule inputs
- Wire gacha app Spin Now to useExecuteGachaRoll, show credits and real items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:08:58 +07:00
Maulana SodiqinandGitHub 8d2aaf5188 Update URLs for Backoffice, Hackathon, QR Campaign, and Infra 2026-04-02 18:10:13 +07:00
Maulana SodiqinandGitHub 3f27e8d622 Create SECURITY.md for security policy
Added a security policy document outlining supported versions and vulnerability reporting.
2026-04-02 13:57:25 +07:00
maulanasdqnandClaude Opus 4.6 629d609792 chore: remove tools/ai-migrations generated by nx migrate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:29:00 +07:00
maulanasdqnandClaude Opus 4.6 8439c5d5e0 ci: make deploy step optional so builds don't fail if server is down
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:13:28 +07:00
maulanasdqnandClaude Opus 4.6 1afd7c014c ci: trigger test build for landing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:09:13 +07:00
maulanasdqnandClaude Opus 4.6 419335d6b3 ci: use single INFRA_DEPLOY_KEY for both GitHub and server SSH
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:06:50 +07:00
maulanasdqnandClaude Opus 4.6 aef8d59c9f ci: simplify deploy - SSH into server and nixos-rebuild
Server pulls pre-built packages from Cachix, no need to build
the NixOS closure in CI. Just update flake.lock and trigger
a remote rebuild.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 14:30:13 +07:00
maulanasdqnandClaude Opus 4.6 e14f6d4ef7 ci: replace update-infra with clan deploy
- Update flake.lock in imphnen-infrastructure
- Deploy to Hetzner server using clan machines update
- Requires SERVER_SSH_KEY secret for server access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 14:27:31 +07:00
maulanasdqnandClaude Opus 4.6 aa3e935fee docs: restore logo in README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 14:20:40 +07:00
maulanasdqnandClaude Opus 4.6 1100d6f417 docs: update README to reflect current project structure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 14:13:04 +07:00
maulanasdqnandClaude Opus 4.6 54ebcf1a1e docs: update CLAUDE.md with project architecture and conventions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 14:04:03 +07:00
maulanasdqnandClaude Opus 4.6 9dabbb625d fix(nix): update npmDepsHash after dependency upgrades
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 14:00:29 +07:00
maulanasdqnandClaude Opus 4.6 3f4461c65c chore: upgrade dependencies, restructure shared libs, and fix UI
- Upgrade Nx 22.1.1 → 22.6.3 and all patch/minor dependencies
- Restructure shared libs: move business logic from utils to service
- Consolidate shadcn-ui into ui lib with atomic design pattern
- Fix container centering for landing app (Tailwind v4 compatibility)
- Fix button styling by updating @source directive in globals.css
- Fix SiCss3 → SiCss rename in react-icons 5.6
- Fix duplicate useSession export conflict
- Remove dead code, comments, and unused files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 01:44:17 +07:00
maulanasdqnandClaude Opus 4.6 f68d97188c ci: only build affected apps using nx affected + matrix strategy
Skip unchanged apps to speed up CI. Uses nx affected to detect
which apps need rebuilding, then runs nix builds in parallel
via GitHub Actions matrix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 23:32:18 +07:00
maulanasdqnandClaude Opus 4.6 f882668e7f ci: update infra flake.lock on merge to develop
After building and pushing to Cachix, the CI now clones
imphnen-infrastructure and updates its flake.lock to point
to the latest frontend service commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 23:21:58 +07:00
Maulana SodiqinandGitHub dab4fa4941 Delete .direnv directory 2026-03-30 23:10:03 +07:00
maulanasdqnandClaude Opus 4.6 bbb1bae909 chore: migrate to Nix flakes, remove Docker and legacy configs
Remove all Docker files, Vercel config, Verdaccio, and old CI workflow.
Standardize on nodejs_22 across devShell and nix build helpers.
Update CI to build all packages and push to Cachix on merge to develop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 23:05:37 +07:00
Hafid NurandGitHub 3de1748f12 fix(qr): Empty State (#76)
* fix(qr): add empty state for campaign & user management page to handle unexcepted null error

* fix(qr): fix empty state logic in the user management page
2026-02-23 10:06:08 +07:00
Hafid Nur a610e180ba fix(qr): update API url to https 2026-02-14 18:29:30 +07:00
Hafid Nur ce8cb8188c fix(qr): hardcode API url 2026-02-14 18:13:28 +07:00
maulanasdqn 3aaf022393 feat(infra): update to include all apps and CI/CD flow 2026-02-14 18:03:58 +07:00
Hafid Nur ebe27a3f05 fix: update API endpoint for QR campaign app 2026-02-14 17:57:03 +07:00
maulanasdqn f846a5a785 fix: use nodejs binary instead of nodejs_22 (cached) 2026-02-14 17:43:42 +07:00
maulanasdqnandClaude Opus 4.5 708cbde596 fix: update npmDepsHash for CI build
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 17:29:40 +07:00
Hafid NurandGitHub 9d37cba65f Add a new app: QR code generator for campaign (#75)
* Initialize a new NX project - QR campaign

* Develop QR code generator app for campaign

* Add deployment script

Add Dockerfile, docker compose, and update Nix config for deployment

* Revert package.json to fix ci

* Revert package-lock.json to ensure ci (Nix build & cache) successful
2026-02-14 17:15:46 +07:00
maulanasdqnandClaude Opus 4.5 8728d0abd7 feat(landing): add Powered By Ancikri badge to hero section
Add clickable "Powered By Ancikri" badge beside the community badge
with matching pill design, linking to ancikri.com

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:58:35 +07:00
maulanasdqnandClaude Opus 4.5 1f4f4ddaa1 feat(landing): add Powered by Ancikri to footer
Add clickable "Powered by Ancikri" link in the footer
pointing to ancikri.com

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:38:19 +07:00
maulanasdqnandClaude Opus 4.5 4fcc249ff4 ci: add Nix build workflow with Cachix
- Build all packages (landing, backoffice, gacha, dimentorin, hackathon, infra)
- Push to msdqn Cachix cache
- Server can pull pre-built packages from Cachix

Requires CACHIX_AUTH_TOKEN secret in GitHub repository settings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:23:45 +07:00
maulanasdqnandClaude Opus 4.5 c2bed98b06 security: patch Next.js RCE vulnerability (CVE-2025-55182)
Upgrade Next.js from 16.0.8 to 16.0.10 to fix critical RCE
vulnerability (CVSS 10.0) in React Server Components.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:09:51 +07:00
maulanasdqn ae1864c134 chore: remove Supabase dependency completely 2026-01-21 20:52:03 +07:00
maulanasdqn 3f30fbe89c fix: make Supabase client lazy to avoid errors when credentials missing 2026-01-21 20:41:28 +07:00
maulanasdqn 951db0f28a feat: add infra app for infrastructure documentation 2026-01-21 20:22:27 +07:00
maulanasdqn ad59d39f55 fix: add react-is dependency for recharts
This is a peer dependency required for recharts to work properly
in production builds.
2026-01-21 18:54:39 +07:00
maulanasdqn 1283689d84 fix(nix): use script command to create pseudo-terminal for Nx
This provides a pty that satisfies Nx's terminal requirements,
preventing the raw terminal mode crash in sandboxed builds.
2026-01-21 18:45:45 +07:00
maulanasdqn 7d1370d7f2 fix(nix): run nx build in background to handle SIGABRT
Background process signal handling allows wait to catch SIGABRT
and || true to suppress the non-zero exit status.
2026-01-21 18:39:05 +07:00
maulanasdqn ab0b56780f fix(nix): use set +e to handle SIGABRT from Nx crash
Signal handling in bash requires set +e to prevent errexit
from terminating the script when a process is killed by SIGABRT.
2026-01-21 18:33:03 +07:00
maulanasdqn 1a39ac37db fix(nix): improve Nx crash recovery for Vite builds
Use '|| true' to always continue past Nx terminal crash,
then verify build output directory exists separately.
2026-01-21 18:25:53 +07:00
maulanasdqnandClaude Opus 4.5 e5dc784301 fix(nix): handle Nx terminal crash for Vite apps
Add fallback check to continue if build output exists even when
Nx crashes after successful build completion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:14:52 +07:00
maulanasdqnandClaude Opus 4.5 b2de22b0ef fix(nix): add --jitless flag to workaround V8 memory issue
Add --jitless Node.js flag to work around V8 JIT compilation
memory allocation issues on the server.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:16:51 +07:00
maulanasdqnandClaude Opus 4.5 ad20eda182 fix(nix): use proper bash path in wrapper script
Fix wrapper script to use explicit Nix bash path instead of
/usr/bin/env bash which doesn't work on NixOS.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:11:39 +07:00
maulanasdqnandClaude Opus 4.5 da65aa4109 chore(nix): update npmDepsHash for local fonts
Update hash after adding font files to the repository.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:02:14 +07:00
maulanasdqnandClaude Opus 4.5 1f0798752c feat(landing): use local fonts instead of Google Fonts
- Download Poppins and Bai Jamjuree font files locally
- Convert from next/font/google to next/font/local
- Enables builds in sandboxed environments without network access

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:55:26 +07:00
maulanasdqnandClaude Opus 4.5 5f28b9b406 fix(nix): allow network access for Google Fonts during build
Add __noChroot=true to enable network access for next/font/google
to fetch Poppins and Bai_Jamjuree fonts during the Next.js build.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:44:07 +07:00
maulanasdqnandClaude Opus 4.5 9e8fc7c07f fix(nix): use --output-style=static to bypass Nx terminal crash
Call Nx directly with --output-style=static flag instead of through
npm scripts to completely bypass the pseudo terminal initialization
that crashes in non-TTY environments.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:34:39 +07:00
maulanasdqnandClaude Opus 4.5 ca51f8aca2 fix(nix): disable Nx native terminal to prevent build crash
Add NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false and NX_NATIVE=false
to completely disable Nx's Rust-based terminal handling that
crashes in non-TTY build environments.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:25:27 +07:00
maulanasdqnandClaude Opus 4.5 c086b1371c fix(nix): add CI environment flags to prevent terminal rendering crash
Added CI=true, NO_COLOR=1, TERM=dumb, and NX_SKIP_NX_CACHE=true
to both mkViteApp.nix and landing.nix to prevent the vt100-rust
panic during Nix builds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:57:17 +07:00
maulanasdqn 398350795d fix: add legacy-peer-deps and makeCacheWritable for npm builds 2026-01-21 14:26:13 +07:00
maulanasdqnandClaude Opus 4.5 5be9ec1edf feat: add Nix flake for reproducible builds and deployment
- Add flake.nix with packages for all 5 apps (landing, gacha, backoffice, dimentorin, hackathon)
- Add NixOS modules for deployment (systemd service for Next.js, nginx for Vite apps)
- Add mkHackathonWithEnv function for build-time environment variables
- Add development shell with Node.js 22, npm, bun

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:02:46 +07:00
Hafid NurandGitHub f77967a092 feat(hackathon): update certificate to include project submission name (#74)
* update hackathon landing page

* feat(hackathon): draft winner certificate

* feat(hackathon): refine winner certificate

* feat(hackathon): update certificate

- add submission project name to the certificate
2025-12-16 13:58:43 +07:00
708 changed files with 35532 additions and 59749 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
+1
View File
@@ -0,0 +1 @@
use flake
+109
View File
@@ -0,0 +1,109 @@
name: Nix Build & Deploy
on:
push:
branches: ['develop']
pull_request:
branches: ['develop']
jobs:
detect:
runs-on: ubuntu-latest
outputs:
affected: ${{ steps.affected.outputs.apps }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Detect affected apps
id: affected
run: |
AFFECTED=$(npx nx show projects --affected --base=HEAD~1 --type=app 2>/dev/null | tr '\n' ' ')
echo "Affected apps: $AFFECTED"
APPS="[]"
for app in landing backoffice gacha dimentorin hackathon infra qrcampaign; do
if echo "$AFFECTED" | grep -qw "$app"; then
APPS=$(echo "$APPS" | jq -c ". + [\"$app\"]")
fi
done
echo "apps=$APPS" >> "$GITHUB_OUTPUT"
echo "Matrix: $APPS"
build:
needs: detect
if: needs.detect.outputs.affected != '[]'
runs-on: ubuntu-latest
strategy:
matrix:
app: ${{ fromJson(needs.detect.outputs.affected) }}
steps:
- uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Setup Cachix
uses: cachix/cachix-action@v15
with:
name: msdqn
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
- name: Build ${{ matrix.app }}
run: nix build .#${{ matrix.app }} -o result-${{ matrix.app }}
- name: Push to Cachix
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
run: cachix push msdqn result-${{ matrix.app }}
deploy:
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
steps:
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Setup SSH
env:
INFRA_DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
run: |
mkdir -p ~/.ssh
echo "${INFRA_DEPLOY_KEY}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
ssh-keyscan 167.235.70.37 >> ~/.ssh/known_hosts 2>/dev/null
- name: Update infra flake.lock
run: |
export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes"
git clone git@github.com:IMPHNEN/imphnen-infrastructure.git /tmp/infra
cd /tmp/infra
nix flake update imphnen-frontend
if git diff --quiet flake.lock; then
echo "flake.lock unchanged, skipping"
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add flake.lock
git commit -m "chore: update imphnen-frontend-service to ${GITHUB_SHA::7}"
git push
fi
- name: Deploy to server
continue-on-error: true
run: |
ssh -i ~/.ssh/deploy_key -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new root@167.235.70.37 \
'nixos-rebuild switch --flake github:IMPHNEN/imphnen-infrastructure#hetzner --refresh 2>&1 | tail -30'
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key
-37
View File
@@ -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
+15 -1
View File
@@ -4,7 +4,7 @@
dist dist
tmp tmp
out-tsc out-tsc
docs/FigmaImage/
# dependencies # dependencies
node_modules node_modules
@@ -66,3 +66,17 @@ out
.github/instructions/nx.instructions.md .github/instructions/nx.instructions.md
.env.local .env.local
# Nix
.direnv
result
.claude/worktrees
.claude/settings.local.json
# Screenshots and Test Reports
**/screenshots
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache
-9
View File
@@ -1,9 +0,0 @@
{
"mcpServers": {
"nx-mcp": {
"type": "stdio",
"command": "npx",
"args": ["nx", "mcp"]
}
}
}
-28
View File
@@ -1,28 +0,0 @@
# path to a directory with all packages
storage: ../tmp/local-registry/storage
# a list of other known repositories we can talk to
uplinks:
npmjs:
url: https://registry.npmjs.org/
maxage: 60m
packages:
'**':
# give all users (including non-authenticated users) full access
# because it is a local registry
access: $all
publish: $all
unpublish: $all
# if package is not available locally, proxy requests to npm registry
proxy: npmjs
# log settings
log:
type: stdout
format: pretty
level: warn
publish:
allow_offline: true # set offline to true to allow publish offline
-8
View File
@@ -1,8 +0,0 @@
{
"recommendations": [
"nrwl.angular-console",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"ms-playwright.playwright"
]
}
+90 -10
View File
@@ -1,13 +1,93 @@
<!-- nx configuration start--> # IMPHNEN Frontend Service
<!-- Leave the start & end comments to automatically receive updates. -->
# General Guidelines for working with Nx Nx monorepo for IMPHNEN (Ingin Menjadi Programmer Handal Namun Enggan Ngoding) — Indonesia's largest programmer community.
- 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 ## Tech Stack
- 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--> - **Monorepo**: Nx 22.6
- **Framework**: Next.js 16 (landing), Vite + React 19 (all other apps)
- **Routing**: react-router v7 (Vite apps), Next.js App Router (landing)
- **Styling**: Tailwind CSS v4, class-variance-authority (CVA)
- **State**: Zustand, TanStack React Query
- **Forms**: react-hook-form + zod
- **Build/Deploy**: Nix flakes, Cachix binary cache, NixOS modules
- **CI/CD**: GitHub Actions with `nx affected` + matrix strategy
- **Node**: v22
## Project Structure
### Apps
| App | Framework | Description |
|-----|-----------|-------------|
| `landing` | Next.js 16 | Public website |
| `backoffice` | Vite + React | Admin dashboard |
| `hackathon` | Vite + React | Hackathon platform |
| `dimentorin` | Vite + React | Mentoring platform |
| `gacha` | Vite + React | Merch gacha system |
| `qrcampaign` | Vite + React | QR campaign tool |
| `infra` | Vite + React | Infrastructure dashboard |
### Shared Libraries
| Lib | Purpose | Depends on |
|-----|---------|------------|
| `utils` | Pure utilities only: `cn`, `For`, `Show`, `useQueryState`, `useModalLogin`, react-query helpers, react-router file-based routing | nothing |
| `service` | Business logic: API clients, auth hooks, storage (SessionToken/SessionUser), constants (PERMISSIONS, cities) | `utils` |
| `ui` | UI components: atoms (Button, Input, Card, Dialog, Drawer, Form, Label), molecules, organisms (Navbar, Sidebar, Datatable) | `utils`, `service` |
**Dependency rule**: `ui``service``utils` (never the reverse)
## Common Commands
```bash
# Dev
nx dev <app> # Start dev server
nx build <app> # Build single app
nx run-many -t build --all # Build everything
# Test
nx test <lib> # Run unit tests (vitest)
nx e2e <app>-e2e # Run e2e tests (playwright)
nx lint <project> # Lint
# Build all affected
nx affected -t build # Only build what changed
nx affected -t test # Only test what changed
# Nix
nix build .#<app> # Build Nix package for an app
nix develop # Enter dev shell (node 22, bun, git, jq)
```
## Nix / Deployment
All Nix config is in a single `flake.nix`:
- `mkViteApp`: Builds Vite apps as Nix packages
- `mkLandingApp`: Builds the Next.js landing app
- `mkLandingModule`: NixOS module (systemd service for Next.js)
- `mkStaticAppModule`: NixOS module (nginx for static Vite apps)
- `npmDepsHash`: Must be updated when `package-lock.json` changes. Use `lib.fakeHash` to get the new hash from a failed build.
## CI/CD Pipeline (.github/workflows/nix-build.yml)
1. **detect**: Uses `nx affected` to find changed apps
2. **build**: Matrix strategy builds only affected apps with Nix, pushes to Cachix
3. **deploy**: Clones `imphnen-infrastructure`, updates `flake.lock`, pushes, then runs `clan machines update hetzner` to deploy to the Hetzner server
Required GitHub secrets: `CACHIX_AUTH_TOKEN`, `INFRA_DEPLOY_KEY` (SSH key for both GitHub and server access)
The server (167.235.70.37) pulls pre-built packages from Cachix during `nixos-rebuild`.
## Environment Variables
- Vite apps: `VITE_API_URL`, `VITE_GITHUB_CLIENT_ID`
- Next.js (landing): `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_GITHUB_CLIENT_ID`
- The `getBaseURL()` function in `libs/service/src/api/index.ts` handles both environments
## Key Conventions
- Atomic design: atoms → molecules → organisms
- Components have their own folder with `component.tsx`, `index.ts`, `spec.tsx`, `stories.tsx`
- Use `cn()` from `@imphnen-frontend-service/utils` for className merging
- Use `'use client'` directive on any component using React hooks (for Next.js compatibility)
- Button variants via CVA: `primary`, `secondary`, `text`, `bordered`, `success`, `danger`
- Landing app uses `container` class — Tailwind v4 requires explicit `margin-inline: auto` (defined in globals.css)
+74 -92
View File
@@ -4,135 +4,117 @@
<img src="docs/logo.svg" alt="IMPHNEN"> <img src="docs/logo.svg" alt="IMPHNEN">
</p> </p>
This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications: Monorepo for all frontend services of [IMPHNEN](https://imphnen.dev) (Ingin Menjadi Programmer Handal Namun Enggan Ngoding) — Indonesia's largest programmer community.
1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Gacha Website</a>. ## Apps
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 | App | Framework | URL |
|-----|-----------|-----|
| **Landing** | Next.js 16 | [imphnen.dev](https://imphnen.dev) |
| **Backoffice** | Vite + React | [backoffice.imphnen.dev](https://backoffice.imphnen.dev) |
| **Hackathon** | Vite + React | [hackathon.imphnen.dev](https://dimentorin.imphnen.dev) |
| **Dimentorin** | Vite + React | [dimentorin.imphnen.dev](https://dimentorin.imphnen.dev) |
| **Gacha** | Vite + React | [gacha.imphnen.dev](https://gacha.imphnen.dev) |
| **QR Campaign** | Vite + React | [qr.imphnen.dev](https://qr.imphnen.dev) |
| **Infra** | Vite + React | [infra.imphnen.dev](https://infra.imphnen.dev) |
## Shared Libraries
| Lib | Purpose |
|-----|---------|
| `utils` | Pure utilities — `cn()`, `For`, `Show`, `useQueryState`, `useModalLogin` |
| `service` | Business logic — API clients, auth hooks, storage, constants |
| `ui` | UI components — atoms, molecules, organisms (atomic design) |
## Getting Started
### Prerequisites
- Node.js 22
- [Nix](https://nixos.org/download/) (optional, for reproducible builds)
### Setup
1. Clone this repository:
```sh ```sh
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
cd imphnen-frontend-service cd imphnen-frontend-service
```
2. Install all dependencies:
```sh
npm install npm install
``` ```
## How to run Or with Nix:
### 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} nix develop # enters dev shell with node 22, bun, git, jq
cp .env.example .env ```
### Environment Variables
Copy `.env.example` to `.env` in the app directory:
```sh
cp apps/<app>/.env.example apps/<app>/.env
``` ```
### Development ### Development
Use the following commands to run in development mode:
- **Gacha**:
```sh ```sh
npm run gacha:dev nx dev <app> # e.g. nx dev landing, nx dev backoffice
```
- **Backoffice**:
```sh
npm run backoffice:dev
```
- **Dimentorin**:
```sh
npm run dimentorin:dev
```
- **Landing Page**:
```sh
npm run landing:dev
``` ```
### Build ### Build
Use the following commands to build the applications:
- **Gacha**:
```sh ```sh
npm run gacha:build nx build <app> # build single app
``` nx run-many -t build --all # build everything
- **Backoffice**: nx affected -t build # build only what changed
```sh
npm run backoffice:build
```
- **Dimentorin**:
```sh
npm run dimentorin:build
```
- **Landing Page**:
```sh
npm run landing:build
``` ```
### Production ### Nix Build
Use the following commands to run the applications in production mode: ```sh
nix build .#<app> # e.g. nix build .#landing, .#dimentorin
```
All Nix config lives in `flake.nix`. When `package-lock.json` changes, update `npmDepsHash` using `lib.fakeHash`.
### Testing
- **Gacha**:
```sh ```sh
npm run gacha:prod nx test <project> # unit tests (vitest)
``` nx e2e <app>-e2e # e2e tests (playwright)
- **Backoffice**: nx lint <project> # eslint
```sh
npm run backoffice:prod
```
- **Dimentorin**:
```sh
npm run dimentorin:prod
```
- **Landing Page**:
```sh
npm run landing:prod
``` ```
### Storybook ### Storybook
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:
- **Run Storybook**
This command starts Storybook in development mode, allowing you to view and test UI components interactively.
```sh ```sh
npm run ui:storybook nx storybook ui # run storybook for ui lib
nx build-storybook ui # build static storybook
``` ```
- **Run Unit Test** ## CI/CD
This command runs unit tests for the UI components to ensure they function as expected. GitHub Actions pipeline (`.github/workflows/nix-build.yml`):
```sh 1. **detect** — uses `nx affected` to find changed apps
npm run ui:test 2. **build** — matrix strategy builds only affected apps with Nix, pushes to [Cachix](https://app.cachix.org/cache/msdqn)
``` 3. **deploy** — updates `flake.lock` in [imphnen-infrastructure](https://github.com/IMPHNEN/imphnen-infrastructure) and deploys to the server using [clan](https://clan.lol)
- **Build Components** ## Tech Stack
This command generates a static build of Storybook, which can be deployed for sharing and documentation purposes. - **Monorepo**: Nx 22.6
- **Frontend**: React 19, TypeScript
- **Styling**: Tailwind CSS v4, CVA
- **State**: Zustand, TanStack React Query
- **Forms**: react-hook-form + zod
- **Build**: Nix flakes, Cachix
- **CI**: GitHub Actions
```sh ## Contributing
npm run ui:build
```
## How to contribute 1. Fork and clone the repository
2. Create a branch: `git checkout -b feat/feature-name`
3. Make changes, commit, and push
4. Open a pull request to the `develop` branch
1. Fork the repository and clone it locally. Issues and feedback welcome via [GitHub Issues](https://github.com/IMPHNEN/imphnen-frontend-service/issues).
2. Create a new branch for a new feature or fix:
```sh
git checkout -b feat/feature-name
```
3. Make changes, commit, and push to your forked repository.
4. Create a pull request to this repository `develop` branch.
If you encounter any issues or problems, feel free to create a new Issue.
+21
View File
@@ -0,0 +1,21 @@
# Security Policy
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
| ------- | ------------------ |
| 5.1.x | :white_check_mark: |
| 5.0.x | :x: |
| 4.0.x | :white_check_mark: |
| < 4.0 | :x: |
## Reporting a Vulnerability
Use this section to tell people how to report a vulnerability.
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.
-1
View File
@@ -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 +1,2 @@
VITE_API_URL= VITE_API_URL=
VITE_GITHUB_CLIENT_ID=
+1 -2
View File
@@ -7,10 +7,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" /> <link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
<link rel="stylesheet" href="/src/styles.css" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"><div style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f9fafb"><div style="width:40px;height:40px;border:3px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:spin .8s linear infinite"></div></div><style>@keyframes spin{to{transform:rotate(360deg)}}</style></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
@@ -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,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,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,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,64 +0,0 @@
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { FC, ReactElement } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
getAdminUsers,
getAdminTeams,
getAdminSubmissions,
} from '@imphnen-frontend-service/service';
export const HackathonDashboardPage: FC = (): ReactElement => {
// Fetch total participants
const { data: usersData } = useQuery({
queryKey: ['admin-users-count'],
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
});
// Fetch total teams
const { data: teamsData } = useQuery({
queryKey: ['admin-teams-count'],
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
});
// Fetch total submissions
const { data: submissionsData } = useQuery({
queryKey: ['admin-submissions-count'],
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
});
const totalParticipants = usersData?.meta?.total_data ?? '??';
const totalTeams = teamsData?.meta?.total_data ?? '??';
const totalSubmissions = submissionsData?.meta?.total_data ?? '??';
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
<section className="grid grid-cols-5 gap-5">
{/* Participant */}
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalParticipants}
</h3>
<p className="text-neutral-400 text-p3">Total Participants</p>
</div>
{/* Team */}
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalTeams}
</h3>
<p className="text-neutral-400 text-p3">Total Teams</p>
</div>
{/* Project Submitted */}
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalSubmissions}
</h3>
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
</div>
</section>
</BackofficeWrapper>
);
};
export default HackathonDashboardPage;
@@ -1,375 +0,0 @@
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import SubmissionModal from './_components/submission-modal';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
SearchOutlined,
FilterOutlined,
LoadingOutlined,
EyeOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import {
getAdminSubmissions,
TAdminSubmissionItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
type SubmissionType = TAdminSubmissionItem;
export const HackathonSubmissionsPage: FC = (): ReactElement => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const statusFilter = searchParams.get('status') || 'all';
const [showSubmissionModal, setShowSubmissionModal] = useState(false);
const [selectedSubmission, setSelectedSubmission] =
useState<SubmissionType | null>(null);
const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Fetch submissions from API
const {
data: submissionsResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-submissions',
currentPage,
perPage,
statusFilter,
searchQuery,
],
queryFn: () =>
getAdminSubmissions({
page: currentPage,
per_page: perPage,
status: statusFilter !== 'all' ? statusFilter : undefined,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
});
const totalData = submissionsResponse?.meta?.total_data || 0;
const totalPages = submissionsResponse?.meta?.total_page || 1;
// Handle page change
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams();
params.set('page', newPage.toString());
if (perPage !== 10) params.set('per_page', perPage.toString());
if (searchQuery) params.set('search', searchQuery);
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams, perPage, searchQuery, statusFilter]
);
// Validate page number
useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
setSearchParams({ page: totalPages.toString() });
}
}, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL
useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
// Handle search
const handleSearch = useCallback(() => {
const params = new URLSearchParams();
params.set('page', '1');
if (perPage !== 10) params.set('per_page', perPage.toString());
if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage, statusFilter]);
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch]
);
// Handle per page change
const handlePerPageChange = useCallback(
(newPerPage: number) => {
const params = new URLSearchParams();
params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
},
[setSearchParams, searchQuery, statusFilter]
);
// Handle status filter change
// const handleStatusFilterChange = useCallback(
// (newStatus: string) => {
// const params = new URLSearchParams();
// params.set('page', '1');
// if (perPage !== 10) params.set('per_page', perPage.toString());
// if (searchQuery) params.set('search', searchQuery);
// if (newStatus !== 'all') params.set('status', newStatus);
// setSearchParams(params);
// },
// [setSearchParams, perPage, searchQuery]
// );
// Handle modal
const handleShowSubmissionModal = useCallback(
(submission: SubmissionType) => {
setSelectedSubmission(submission);
setShowSubmissionModal(true);
},
[]
);
const handleCloseSubmissionModal = useCallback(() => {
setShowSubmissionModal(false);
setSelectedSubmission(null);
}, []);
// Get submissions data
const filteredData = useMemo(() => {
return submissionsResponse?.data || [];
}, [submissionsResponse]);
// Memoize columns
const columns: ColumnDef<SubmissionType>[] = useMemo(
() => [
{
accessorKey: 'project_name',
header: 'Project Name',
cell: ({ row }) => (
<span className="font-medium text-neutral-900">
{row.original.project_name}
</span>
),
enableSorting: true,
},
{
accessorKey: 'team_id',
header: 'Team ID',
cell: ({ row }) => (
<span className="text-sm text-neutral-700 font-mono">
{row.original.team_id}
</span>
),
enableSorting: false,
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const status = row.original.status;
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
status === 'submitted'
? 'bg-success-100 text-success-800'
: status === 'pending'
? 'bg-orange-100 text-orange-800'
: 'bg-neutral-100 text-neutral-700'
)}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
},
enableSorting: true,
},
{
accessorKey: 'submitted_at',
header: 'Submitted',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
{new Date(row.original.submitted_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
),
enableSorting: true,
sortingFn: 'datetime',
},
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowSubmissionModal(row.original)}
>
<EyeOutlined className="text-sm" />
View
</Button>
),
enableSorting: false,
},
],
[handleShowSubmissionModal]
);
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Project Submissions
</h1>
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
<div className="flex flex-wrap gap-3 items-center">
{/* Search bar */}
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search by project name..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
</div>
{/* Per Page Dropdown */}
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{/* Status Filter */}
{/* <div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={statusFilter}
onChange={(e) => handleStatusFilterChange(e.target.value)}
>
<option value="all">All Status</option>
<option value="submitted">Submitted</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</div> */}
</div>
</div>
{/* Active filters */}
{/* {statusFilter !== 'all' && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Status: {statusFilter}
<button
onClick={() => handleStatusFilterChange('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</span>
<Button
variant="secondary"
size="sm"
onClick={() => {
handleStatusFilterChange('all');
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)} */}
{/* Loading & results */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">
Loading submissions...
</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} submissions (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No submissions found. Try adjusting your filters.
</div>
)}
</section>
{/* Submission Modal */}
{selectedSubmission && (
<SubmissionModal
isOpen={showSubmissionModal}
onClose={handleCloseSubmissionModal}
submission={selectedSubmission}
/>
)}
</BackofficeWrapper>
);
};
export default HackathonSubmissionsPage;
@@ -1,447 +0,0 @@
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import ModalTeamDetail from './_components/modal-team-detail-new';
import { CityFilterSelect } from '../../../components/city-filter-select';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
EditOutlined,
TeamOutlined,
SearchOutlined,
FilterOutlined,
PlusOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import {
getAdminTeams,
TAdminTeamItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
type TeamType = TAdminTeamItem;
export const HackathonTeamsPage: FC = (): ReactElement => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showNewTeamModal, setShowNewTeamModal] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null);
useState<TeamType | null>(null);
const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Advanced filtering states
const [visibilityFilter, setVisibilityFilter] = useState('all');
const [cityFilter, setCityFilter] = useState('all');
// Fetch teams from API
const {
data: teamsResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-teams',
currentPage,
perPage,
cityFilter,
visibilityFilter,
searchQuery,
],
queryFn: () =>
getAdminTeams({
page: currentPage,
per_page: perPage,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
});
const totalData = teamsResponse?.meta?.total_data || 0;
const totalPages = teamsResponse?.meta?.total_page || 1;
// Handle page change - update URL query params
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams();
params.set('page', newPage.toString());
if (perPage !== 10) params.set('per_page', perPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams, perPage, searchQuery]
);
// Validate page number doesn't exceed total pages
useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
setSearchParams({ page: totalPages.toString() });
}
}, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL search param on mount
useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
// Handle search teams
const handleSearch = useCallback(() => {
const params = new URLSearchParams();
params.set('page', '1');
if (perPage !== 10) params.set('per_page', perPage.toString());
if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage]);
// Handle Enter key press in search input
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch]
);
// Handle per page change
const handlePerPageChange = useCallback(
(newPerPage: number) => {
const params = new URLSearchParams();
params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
},
[setSearchParams, searchQuery]
);
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((team: TeamType) => {
setSelectedTeam(team);
setShowDetailModal(true);
}, []);
const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false);
setSelectedTeam(null);
}, []);
const handleShowNewTeamModal = useCallback(() => {
setShowNewTeamModal(true);
}, []);
const handleCloseNewTeamModal = useCallback(() => {
setShowNewTeamModal(false);
}, []);
// Get teams data from API response
const filteredData = useMemo(() => {
return teamsResponse?.data || [];
}, [teamsResponse]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef<TeamType>[] = useMemo(
() => [
{
accessorKey: 'name',
header: 'Team',
cell: ({ row }) => {
const team = row.original;
return (
<div className="flex items-center gap-3">
{/* Team Logo */}
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-full h-full object-cover"
/>
) : (
<TeamOutlined className="text-neutral-400 text-lg" />
)}
</div>
{/* Team Name */}
<div className="min-w-0 flex-1">
<p
className="font-medium text-neutral-900 truncate max-w-sm"
title={team.name}
>
{team.name}
</p>
</div>
</div>
);
},
enableSorting: true,
},
{
accessorKey: 'city',
header: 'City',
cell: ({ row }) => (
<span className="text-neutral-700">{row.original.city}</span>
),
enableSorting: true,
},
{
accessorKey: 'visibility',
header: 'Visibility',
cell: ({ row }) => {
const isPublic = row.original.visibility === 'public';
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
isPublic
? 'bg-success-100 text-success-800'
: 'bg-neutral-100 text-neutral-700'
)}
>
{isPublic ? 'Public' : 'Private'}
</span>
);
},
enableSorting: true,
},
{
id: 'leader',
header: 'Leader ID',
cell: ({ row }) => (
<div className="text-sm text-neutral-700 font-mono">
{row.original.leader_id}
</div>
),
enableSorting: false,
},
{
accessorKey: 'created_at',
header: 'Created',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
),
enableSorting: true,
sortingFn: 'datetime',
},
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowDetailModal(row.original)}
>
<EditOutlined className="text-sm" />
Manage
</Button>
</div>
),
enableSorting: false,
},
],
[handleShowDetailModal]
);
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Team Management
</h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
{/* Left side - Search & filters */}
<div className="flex flex-wrap gap-3 items-center">
{/* Search bar */}
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search teams by name or city..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
</div>
{/* Per Page Dropdown */}
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{/* Visibility Filter */}
{/* <div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={visibilityFilter}
onChange={(e) => setVisibilityFilter(e.target.value)}
>
<option value="all">All Visibility</option>
<option value="public">Public</option>
<option value="private">Private</option>
</select>
</div> */}
{/* City Filter */}
{/* <CityFilterSelect
value={cityFilter}
onChange={setCityFilter}
className="w-full sm:w-44"
placeholder="Search cities..."
allOptionLabel="All Cities"
/> */}
</div>
{/* Right side - Add Team Button */}
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewTeamModal}
>
<PlusOutlined className="text-sm" />
Add Team
</Button>
</div>
</div>
{/* Active filters display */}
{(visibilityFilter !== 'all' || cityFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
{/* Visibility filter badge */}
{visibilityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Visibility: {visibilityFilter}
<button
onClick={() => setVisibilityFilter('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</span>
)}
{/* City filter badge */}
{cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
City: {cityFilter}
<button
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
</button>
</span>
)}
{/* Clear all filters */}
<Button
variant="secondary"
size="sm"
onClick={() => {
setVisibilityFilter('all');
setCityFilter('all');
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)}
{/* Loading & results display */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">Loading teams...</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} teams (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No teams found. Try adjusting your filters.
</div>
)}
</section>
{/* Modals component */}
<ModalTeamDetail
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
team={selectedTeam}
/>
{/* New Team Modal */}
<ModalTeamDetail
isOpen={showNewTeamModal}
onClose={handleCloseNewTeamModal}
team={null} // null indicates creating new team
/>
</BackofficeWrapper>
);
};
export default HackathonTeamsPage;
@@ -1,573 +0,0 @@
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import ModalUserDetail from './_components/modal-user-detail';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
EditOutlined,
UserOutlined,
SearchOutlined,
FilterOutlined,
PlusOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { CityFilterSelect } from '../../../components/city-filter-select';
import { useQuery } from '@tanstack/react-query';
import {
getAdminUsers,
TAdminUserItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
type UserType = TAdminUserItem;
// Skills options for filter
const skillsOptions = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
export const HackathonUsersPage: FC = (): ReactElement => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showNewUserModal, setShowNewUserModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Advanced filtering states
const [statusFilter, setStatusFilter] = useState('all');
const [cityFilter, setCityFilter] = useState('all');
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
// Fetch users from API
const {
data: usersResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-users',
currentPage,
perPage,
cityFilter,
statusFilter,
searchQuery,
],
queryFn: () =>
getAdminUsers({
page: currentPage,
per_page: perPage,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
});
const totalData = usersResponse?.meta?.total_data || 0;
const totalPages = usersResponse?.meta?.total_page || 1;
// Handle page change - update URL query params
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams();
params.set('page', newPage.toString());
if (perPage !== 10) params.set('per_page', perPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams, perPage, searchQuery]
);
// Validate page number doesn't exceed total pages
useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
setSearchParams({ page: totalPages.toString() });
}
}, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL search param on mount
useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
// Handle search users
const handleSearch = useCallback(() => {
const params = new URLSearchParams();
params.set('page', '1');
if (perPage !== 10) params.set('per_page', perPage.toString());
if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage]);
// Handle Enter key press in search input
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch]
);
// Handle per page change
const handlePerPageChange = useCallback(
(newPerPage: number) => {
const params = new URLSearchParams();
params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
},
[setSearchParams, searchQuery]
);
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((user: UserType) => {
setSelectedUser(user);
setShowDetailModal(true);
}, []);
const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false);
setSelectedUser(null);
}, []);
const handleShowNewUserModal = useCallback(() => {
setShowNewUserModal(true);
}, []);
const handleCloseNewUserModal = useCallback(() => {
setShowNewUserModal(false);
}, []);
// Filter data based on current filter states
const filteredData = useMemo(() => {
const usersData = usersResponse?.data || [];
return usersData.filter((user: UserType) => {
// Status filter
if (statusFilter !== 'all') {
const isActive = statusFilter === 'active';
if (user.is_active !== isActive) return false;
}
// Skills filter
if (skillsFilter.length > 0) {
const userSkills = user.skills || [];
const hasMatchingSkill = skillsFilter.some((skill) =>
userSkills.includes(skill)
);
if (!hasMatchingSkill) return false;
}
return true;
});
}, [usersResponse, statusFilter, skillsFilter]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef<UserType>[] = useMemo(
() => [
{
accessorKey: 'fullname',
header: 'User',
cell: ({ row }) => (
<div className="flex items-center gap-3">
{/* Avatar */}
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
{row.original.avatar ? (
<img
src={row.original.avatar}
alt={row.original.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500 text-lg" />
)}
</div>
{/* Name only */}
<div className="min-w-0 flex-1">
<p className="font-medium text-neutral-900 truncate">
{row.original.fullname}
</p>
</div>
</div>
),
enableSorting: true,
},
{
accessorKey: 'skills',
header: 'Skills',
cell: ({ row }) => {
const skills = row.original.skills || [];
return (
<div className="flex flex-wrap gap-1 max-w-xs">
{skills.length > 0 ? (
<>
{skills.slice(0, 2).map((skill, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
>
{skill.replace(' Developer', '').replace(' Engineer', '')}
</span>
))}
{skills.length > 2 && (
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
+{skills.length - 2}
</span>
)}
</>
) : (
<span className="text-neutral-400">-</span>
)}
</div>
);
},
enableSorting: false,
},
{
accessorKey: 'location',
header: 'Location',
cell: ({ row }) => (
<span className="text-neutral-700">{row.original.location}</span>
),
enableSorting: true,
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
)}
/>
<span
className={cn(
'text-sm font-medium',
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
)}
>
{row.original.is_active ? 'Active' : 'Inactive'}
</span>
</div>
),
enableSorting: true,
sortingFn: (rowA, rowB) => {
const aActive = rowA.original.is_active;
const bActive = rowB.original.is_active;
if (aActive && !bActive) return -1;
if (!aActive && bActive) return 1;
return 0;
},
},
{
accessorKey: 'created_at',
header: 'Joined',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
{new Date(row.original.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
),
enableSorting: true,
sortingFn: 'datetime',
},
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowDetailModal(row.original)}
>
<EditOutlined className="text-sm" />
Manage
</Button>
{/* <Button
variant="secondary"
size="sm"
className="text-sm px-4 py-2"
onClick={() => {
// Toggle user status - implement later
console.log(`Toggle status for ${row.original.fullname}`);
}}
>
{row.original.is_active ? 'Deactivate' : 'Activate'}
</Button> */}
</div>
),
enableSorting: false,
},
],
[handleShowDetailModal]
);
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
User Management
</h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
{/* Left side - Search & filters */}
<div className="flex flex-wrap gap-3 items-center">
{/* Search bar */}
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search users by name or location..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
</div>
{/* Per Page Dropdown */}
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{/* Status Filter */}
{/* <div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div> */}
{/* City Filter */}
{/* <CityFilterSelect
value={cityFilter}
onChange={setCityFilter}
className="w-full sm:w-44"
placeholder="Search cities..."
allOptionLabel="All Cities"
/>
{cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
Location: {cityFilter}
<button
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
</button>
</span>
)} */}
{/* Skills Filter with Icon */}
{/* <div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value=""
onChange={(e) => {
if (
e.target.value &&
!skillsFilter.includes(e.target.value)
) {
setSkillsFilter((prev) => [...prev, e.target.value]);
}
}}
>
<option value="">Add Skill Filter</option>
{skillsOptions.map((skill) => (
<option
key={skill}
value={skill}
disabled={skillsFilter.includes(skill)}
>
{skill}
</option>
))}
</select>
</div> */}
</div>
{/* Right side - Add User Button */}
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewUserModal}
>
<PlusOutlined className="text-sm" />
Add User
</Button>
</div>
</div>
{/* Active filters display */}
{(skillsFilter.length > 0 ||
statusFilter !== 'all' ||
cityFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
{/* Status filter badge */}
{statusFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Status: {statusFilter}
<button
onClick={() => setStatusFilter('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</span>
)}
{/* Location filter badge */}
{cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
City: {cityFilter}
<button
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
</button>
</span>
)}
{/* Skills filter badges */}
{skillsFilter.map((skill) => (
<span
key={skill}
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
>
{skill.replace(' Developer', '').replace(' Engineer', '')}
<button
onClick={() =>
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
}
className="text-purple-600 hover:text-purple-800 cursor-pointer"
>
</button>
</span>
))}
{/* Clear all filters */}
<Button
variant="secondary"
size="sm"
onClick={() => {
setStatusFilter('all');
setCityFilter('all');
setSkillsFilter([]);
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)}
{/* Loading & results display */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">Loading users...</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} users (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No users found. Try adjusting your filters.
</div>
)}
</section>
{/* Modals component */}
<ModalUserDetail
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
user={selectedUser}
/>
{/* New User Modal */}
<ModalUserDetail
isOpen={showNewUserModal}
onClose={handleCloseNewUserModal}
user={null} // null indicates creating new user
/>
</BackofficeWrapper>
);
};
export default HackathonUsersPage;
@@ -1,56 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar
isOpen={mobileSidebarOpen}
onClose={() => setMobileSidebarOpen(false)}
/>
<div className="flex-1 overflow-auto">
{/* Sticky top header */}
<header
className={
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
(mobileSidebarOpen ? 'z-0' : 'z-30')
}
>
{/* Mobile menu button (shown on small screens) */}
<button
type="button"
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer"
onClick={() => setMobileSidebarOpen(true)}
aria-label="Open sidebar"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="text-p3 font-semibold text-primary-700">
IMPHNEN Backoffice
</h1>
</header>
<Outlet />
</div>
</div>
</div>
);
};
export default AppLayout;
@@ -1,132 +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 IModalAddPermission {
isOpen: boolean;
onClose: () => void;
handleAddItem?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
}
const ModalAddPermission = ({
isOpen,
onClose,
currentStep,
nextStep,
resetStep,
handleAddItem,
}: IModalAddPermission) => {
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-3 text-center"
isOpen={isOpen}
onClose={() => {
onClose();
resetStep();
}}
disableEscapeKeyDown={true}
>
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
{currentStep === 2 && (
<StepTwo
onClose={onClose}
handleAddItem={handleAddItem}
resetStep={resetStep}
/>
)}
</Modal>
);
};
interface IStepOneProps {
nextStep: () => void;
onClose: () => void;
}
const StepOne = ({ nextStep }: IStepOneProps) => {
const { form, onSubmit } = useItem(nextStep);
return (
<>
<Modal.Header>
<h2 className="text-p1 font-semibold text-primary-500">
Tambah Permissions
</h2>
</Modal.Header>
<Modal.Content>
<form onSubmit={onSubmit} className="flex flex-col gap-8">
<ControlledInputField
control={form.control}
label="Name"
name="name"
type="text"
placeholder="Nama Permission"
size="lg"
className="w-full"
/>
<Button variant="primary" size="lg" className="w-full" type="submit">
Tambah Permission
</Button>
</form>
</Modal.Content>
</>
);
};
interface IStepTwoProps {
onClose: () => void;
handleAddItem?: () => Promise<boolean>;
resetStep: () => void;
}
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => {
const { onConfirm, onCancel } = useConfirmItem(
onClose,
resetStep,
handleAddItem,
{
success: 'Data permissions berhasil ditambahkan',
error: 'Data permissions gagal ditambahkan',
}
);
return (
<>
<Modal.Header className="mb-7 text-center items-center">
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Tambah Permissions
</h2>
<p className="text-p3 text-center text-neutral-400">
Apakah kamu yakin ingin
<br /> menambahkan permission ini?
</p>
</Modal.Header>
<Modal.Content className="flex mb-0 gap-4">
<Button
variant="bordered"
size="lg"
className="w-full"
onClick={onCancel}
>
Batal
</Button>
<Button
variant="primary"
size="lg"
className="w-full"
onClick={onConfirm}
>
Tambahkan
</Button>
</Modal.Content>
</>
);
};
export default ModalAddPermission;
@@ -1,72 +0,0 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { useConfirmItem } from '../_hook/use-item';
interface IModalDeletePermission {
isOpen: boolean;
onClose: () => void;
handleDelete?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
}
const ModalDeletePermission = ({
isOpen,
onClose,
resetStep,
handleDelete,
}: IModalDeletePermission) => {
const { onConfirm } = useConfirmItem(onClose, resetStep, handleDelete, {
success: 'Data permissions berhasil dihapus',
error: 'Data permissions gagal dihapus',
});
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
isOpen={isOpen}
onClose={onClose}
closeButtonClassName="hidden"
>
<Modal.Header className="gap-8">
<img
src="/chibi-delete.webp"
alt="Delete?"
width={148}
className="self-center"
/>
<div className="text-center">
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
Delete Permissions
</h2>
<p className="text-p3 text-neutral-400">
Apakah kamu yakin untuk menghapus permission ini? Menghapus data ini
mungkin akan mempengaruhi fungsional sistem
</p>
</div>
</Modal.Header>
<Modal.Content className="flex gap-4">
<Button
variant="secondary"
size="lg"
className="w-full"
onClick={onClose}
>
Batal Hapus
</Button>
<Button
variant="primary"
size="lg"
className="w-full bg-danger-500 hover:bg-danger-600"
onClick={onConfirm}
>
Hapus Item
</Button>
</Modal.Content>
</Modal>
);
};
export default ModalDeletePermission;
@@ -1,62 +0,0 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
import { useConfirmItem } from '../_hook/use-item';
interface IModalUpdatePermission {
isOpen: boolean;
onClose: () => void;
handleUpdate?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
}
const ModalUpdatePermission = ({
isOpen,
onClose,
resetStep,
handleUpdate,
}: IModalUpdatePermission) => {
const { onConfirm } = useConfirmItem(onClose, resetStep, handleUpdate, {
success: 'Perubahan permissions berhasil dilakukan',
error: 'Perubahan permissions gagal dilakukan',
});
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-3 text-center"
isOpen={isOpen}
onClose={onClose}
disableEscapeKeyDown={true}
>
<Modal.Header>
<h2 className="text-p1 font-semibold text-primary-500">
Update Permissions
</h2>
</Modal.Header>
<Modal.Content className="flex flex-col gap-8">
<InputField
label="Name"
name="name"
type="text"
placeholder="Nama Permission"
size="lg"
className="w-full"
/>
<Button
variant="primary"
size="lg"
className="w-full"
type="submit"
onClick={onConfirm}
>
Update Permission
</Button>
</Modal.Content>
</Modal>
);
};
export default ModalUpdatePermission;
@@ -1,202 +0,0 @@
import { FC, Fragment, ReactElement, useState } from 'react';
import {
SearchOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import ModalAddPermission from './_components/modal-add-permission';
import ModalUpdatePermission from './_components/modal-update-permission';
import ModalDeletePermission from './_components/modal-delete-permission';
import { useQueryState } from '@imphnen-frontend-service/utils';
import React from 'react';
interface Permission {
id: number;
name: string;
}
const mockData: Permission[] = [
{ id: 1, name: 'Read' },
{ id: 2, name: 'Create' },
{ id: 3, name: 'Update' },
{ id: 4, name: 'Delete' },
];
export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false);
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const columns: ColumnDef<Permission>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Action',
cell: () => (
<div className="flex gap-[8px]">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalUpdateItem(true);
}}
className="flex items-center gap-2"
>
<EditOutlined /> Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalDeleteItem(true);
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
</div>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Permissions</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama permissions"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => {
setShowModalAddItem(true);
}}
>
<PlusOutlined />
Tambah Permissionss
</Button>
</div>
</div>
<DataTable
data={mockData}
columns={columns}
pageSize={9}
table={table}
/>
</section>
</main>
<ModalAddPermission
currentStep={currentStep}
isOpen={showModalAddItem}
onClose={() => setShowModalAddItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
<ModalUpdatePermission
isOpen={showModalUpdateItem}
onClose={() => setShowModalUpdateItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
<ModalDeletePermission
isOpen={showModalDeleteItem}
onClose={() => setShowModalDeleteItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
</Fragment>
);
};
export default Components;
@@ -1,251 +0,0 @@
import * as React from 'react';
import { FC, Fragment, ReactElement, useState } from 'react';
import {
FilterOutlined,
SearchOutlined,
AuditOutlined,
} from '@ant-design/icons';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
useReactTable,
RowSelectionState,
} from '@tanstack/react-table';
import ModalProcessDelivery from './_components/modal-process-item';
type OrderValid = 'valid' | 'invalid' | 'unchecked';
type Status = 'undelivered' | 'delivered';
interface Prize {
id: number;
name: string;
orderValid: OrderValid;
items: string;
address: string;
status: Status;
}
const items = [
'Sertifikat + Laminating',
'Lanyard + ID Card',
'Pin',
'Sticker Isi 3',
'Sticker Isi 5',
'Gelang Karet',
];
// Mock data for transactions
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: 'Nama Lengkap',
orderValid: (i % 3 === 0
? 'invalid'
: i % 5 === 0
? 'unchecked'
: 'valid') as OrderValid,
items: items[i % items.length],
address: 'Jl. Pantai Cibaduyut Indah',
status: (i % 3 === 0 ? 'undelivered' : 'delivered') as Status,
}));
export const Components: FC = (): ReactElement => {
const [showModalProcessDelivery, setShowModalProcessDelivery] =
useState(false);
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = useState(false);
const deliveryOptions = [
{ id: 'option1', value: 'undelivered', label: 'Undelivered' },
{ id: 'option1', value: 'delivered', label: 'Delivered' },
];
const columns: ColumnDef<Prize>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Nama Lengkap',
accessorKey: 'name',
},
{
header: 'Order Valid?',
accessorKey: 'orderValid',
cell: ({ row }) => {
const status = row.original.orderValid;
const statusColors: Record<OrderValid, string> = {
valid: 'bg-success-200 text-success-500',
invalid: 'bg-danger-200 text-danger-500',
unchecked: 'bg-warning-200 text-warning-900',
};
const statusText: Record<OrderValid, string> = {
valid: 'Valid',
invalid: 'Invalid',
unchecked: 'Unchecked',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Items',
accessorKey: 'items',
},
{
header: 'Alamat Pengiriman',
accessorKey: 'address',
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<Status, string> = {
delivered: 'bg-success-200 text-success-500',
undelivered: 'bg-danger-200 text-danger-500',
};
const statusText: Record<Status, string> = {
delivered: 'Delivered',
undelivered: 'Undelivered',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalProcessDelivery(true);
}}
className="flex items-center gap-2 w-full"
>
<AuditOutlined className="text-[16px]" /> Process
</Button>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
{/* Header */}
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Data Pengiriman Hadiah</h1>
</header>
{/* Account Table Section */}
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
{/* Search and Filter */}
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex items-center gap-3"
onClick={() => setShowFilter(!showFilter)}
>
<FilterOutlined />
Filters
</Button>
{showFilter && (
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
<Filter
options={deliveryOptions}
onClose={() => setShowFilter(false)}
onFilterChange={(value) => {
console.log('Selected filter:', value);
// Filter logic di sini
}}
/>
</div>
)}
</div>
</div>
{/* Table */}
<DataTable data={mockData} columns={columns} table={table} />
</section>
</main>
{/* Modal Process Delivery */}
<ModalProcessDelivery
isOpen={showModalProcessDelivery}
onClose={() => setShowModalProcessDelivery(false)}
handleProcessDelivery={() => {
console.log('Action ketika user menekan tombol Proses Pengiriman');
}}
/>
</Fragment>
);
};
export default Components;
@@ -1,85 +0,0 @@
import { ArrowRightOutlined } from "@ant-design/icons";
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
import { Accordion, Modal, ModalProps } from "@imphnen-frontend-service/ui/molecules";
import { cn, For } from "@imphnen-frontend-service/utils";
import { FC } from "react";
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
export const ModalCreateRoadmap: FC<Omit<ModalProps, 'children'>> = ({ isOpen, onClose }) => {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="xl:max-w-[64rem] bg-white px-10 py-9"
closeButtonClassName="hidden"
>
<div className="overflow-y-auto max-h-[80vh]">
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Create Roadmaps
</h1>
<div className="grid grid-cols-5 gap-6 mb-12">
<div className="col-span-3">
<label className={labelClass}>Prompt</label>
<Textarea
className="min-w-full w-full h-[calc(100%-2rem)]"
placeholder="Create a learning roadmap for (subject) at the (level) level. Include topics, estimated duration, and logical progression."
/>
</div>
<div className="col-span-2 space-y-8">
<div>
<label className={labelClass}>Roadmap Name</label>
<Input type="text" className="min-w-full w-full" placeholder="Nama Roadmap" />
</div>
<div>
<label className={labelClass}>Roadmap Name</label>
<Select className="w-full">
<option value="pemula">Pemula</option>
<option value="menengah">Menengah</option>
</Select>
</div>
<div>
<label className={labelClass}>Model</label>
<Select className="w-full">
<option value="gpt-3.5-turbo">GPT 3.5 Turbo</option>
<option value="gpt-4">GPT 4</option>
<option value="gpt-4-32k">GPT 4 32k</option>
</Select>
</div>
</div>
<div className="col-span-full">
<Button>
Generate Roadmap
</Button>
</div>
</div>
<div>
<h2 className="text-neutral-600 text-p2 font-semibold mb-8">
Roadmap Preview
</h2>
<div className="space-y-2.5">
<For data={Array.from({ length: 3 })}>
{(_, index) => (
<Accordion
key={index}
title={`Day ${index + 1}`}
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit. In convallis tincidunt nisl, id consequat mi malesuada vel. Nulla facilisi. Nam in turpis ligula."
/>
)}
</For>
</div>
</div>
<div className="flex justify-end mt-12">
<Button type="button" className="flex items-center gap-5">
Submit Roadmap
<ArrowRightOutlined />
</Button>
</div>
</div>
</Modal>
)
}
@@ -1,174 +0,0 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, 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 } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { useState } from "react";
import { ModalCreateRoadmap } from "./_components/modal/create-roadmap";
type LearningStatus = 'active' | 'inactive'
type RoadmapType = {
id: number
name: string
learningLevel: string
status: LearningStatus
}
const mockData: RoadmapType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i === 0 ? 'Ahmad Wijuana' : 'Anna Wiguana',
learningLevel: ['Pemula', 'Menengah'][Math.floor(Math.random() * 2)],
status: i % 2 === 0 ? 'active' : 'inactive',
}))
export default function Components(): React.ReactElement {
const [openCreateModal, setOpenCreateModal] = useState(false)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<RoadmapType>[] = [
{
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: 'id',
header: 'No',
accessorKey: 'id',
},
{
id: 'name',
header: 'Nama Roadmap',
accessorKey: 'name',
},
{
id: 'learningLevel',
header: 'Tingkat Belajar',
accessorKey: 'learningLevel',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<LearningStatus, string> = {
inactive: 'bg-danger-200 text-danger-700',
active: 'bg-success-200 text-success-500',
};
const statusText: Record<LearningStatus, string> = {
inactive: 'Inactive',
active: 'Active',
};
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 }) => (
<div className="flex gap-[8px]">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-2"
>
<EditOutlined /> Action
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
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 (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Content & Roadmap</h1>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex items-center justify-between mb-9">
<h2 className="text-p2 font-semibold text-neutral-600">AI Roadmaps</h2>
<Button type="button" variant="primary" className="flex items-center gap-2" onClick={() => setOpenCreateModal(true)}>
<PlusOutlined /> Buat Roadmap
</Button>
</div>
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama roadmap"
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>Tingkat Belajar</option>
<option value="pemula">Pemula</option>
<option value="menengah">Menengah</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
<ModalCreateRoadmap isOpen={openCreateModal} onClose={() => setOpenCreateModal(false)} />
</BackofficeWrapper>
)
}
@@ -1,185 +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 IModalAddRole {
isOpen: boolean;
onClose: () => void;
handleAdd?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
}
const ModalAddRole = ({
isOpen,
onClose,
currentStep,
nextStep,
resetStep,
handleAdd,
}: IModalAddRole) => {
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-0 text-center"
isOpen={isOpen}
onClose={() => {
onClose();
resetStep();
}}
disableEscapeKeyDown={true}
>
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
{currentStep === 2 && (
<StepTwo
onClose={onClose}
handleAdd={handleAdd}
resetStep={resetStep}
/>
)}
</Modal>
);
};
interface IStepOneProps {
nextStep: () => void;
onClose: () => void;
}
const StepOne = ({ nextStep }: IStepOneProps) => {
const { form, onSubmit } = useItem(nextStep);
return (
<>
<Modal.Header>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Tambah Roles
</h2>
</Modal.Header>
<Modal.Content>
<form onSubmit={onSubmit} className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<ControlledInputField
control={form.control}
label="Nama Role"
name="name"
type="text"
placeholder="Masukkan Nama Role"
size="lg"
className="w-full"
/>
</div>
<div className="flex flex-col gap-4 items-start overflow-auto">
<span className="text-p3 font-medium text-neutral-800 sticky left-0">
Permissions
</span>
<div className="flex gap-x-6 overflow-x-scroll">
{[
'Gacha Items',
'Gacha Roll',
'Roll',
'Users',
'Gacha Claim',
].map((title) => (
<div
key={title}
className="flex flex-col gap-4 select-none text-label2 font-medium text-neutral-900 "
>
<span className="text-nowrap text-label1">{title}</span>
<div className="flex gap-[8px] items-center">
<input
type="checkbox"
id={`${title}-all`}
className="rounded"
/>
<label htmlFor={`${title}-all`} className="text-nowrap">
Check All
</label>
</div>
<hr className="border-blue-200" />
<div className="flex flex-col items-start gap-4 mb-4">
<div className="flex gap-[8px] items-center">
<input type="checkbox" id={`${title}-read`} />
<label htmlFor={`${title}-read`}>Read</label>
</div>
<div className="flex gap-[8px] items-center">
<input type="checkbox" id={`${title}-create`} />
<label htmlFor={`${title}-create`}>Create</label>
</div>
<div className="flex gap-[8px] items-center">
<input type="checkbox" id={`${title}-update`} />
<label htmlFor={`${title}-update`}>Update</label>
</div>
<div className="flex gap-[8px] items-center">
<input type="checkbox" id={`${title}-delete`} />
<label htmlFor={`${title}-delete`}>Delete</label>
</div>
</div>
</div>
))}
</div>
</div>
<Button variant="primary" size="lg" className="w-full" type="submit">
Tambah Role
</Button>
</form>
</Modal.Content>
</>
);
};
interface IStepTwoProps {
onClose: () => void;
handleAdd?: () => Promise<boolean>;
resetStep: () => void;
}
const StepTwo = ({ onClose, handleAdd, resetStep }: IStepTwoProps) => {
const { onConfirm, onCancel } = useConfirmItem(
onClose,
resetStep,
handleAdd,
{
success: 'Data role berhasil ditambahkan',
error: 'Data role gagal ditambahkan',
}
);
return (
<>
<Modal.Header className="mb-10 text-center items-center">
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Tambah Roles
</h2>
<p className="text-p3 text-center text-neutral-400">
Apakah kamu yakin ingin
<br /> menambahkan role ini?
</p>
</Modal.Header>
<Modal.Content className="flex mb-0 gap-4">
<Button
variant="bordered"
size="lg"
className="w-full"
onClick={onCancel}
>
Batal
</Button>
<Button
variant="primary"
size="lg"
className="w-full"
onClick={onConfirm}
>
Tambahkan
</Button>
</Modal.Content>
</>
);
};
export default ModalAddRole;
@@ -1,72 +0,0 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { useConfirmItem } from '../_hook/use-item';
interface IModalDeletePermission {
isOpen: boolean;
onClose: () => void;
handleDelete?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
}
const ModalDeletePermission = ({
isOpen,
onClose,
resetStep,
handleDelete,
}: IModalDeletePermission) => {
const { onConfirm } = useConfirmItem(onClose, resetStep, handleDelete, {
success: 'Data roles berhasil dihapus',
error: 'Data roles gagal dihapus',
});
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
isOpen={isOpen}
onClose={onClose}
closeButtonClassName="hidden"
>
<Modal.Header className="gap-8">
<img
src="/chibi-delete.webp"
alt="Delete?"
width={148}
className="self-center"
/>
<div className="text-center">
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
Delete Roles
</h2>
<p className="text-p3 text-neutral-400">
Apakah kamu yakin untuk menghapus role ini? Menghapus data ini
mungkin akan mempengaruhi fungsional sistem
</p>
</div>
</Modal.Header>
<Modal.Content className="flex gap-4">
<Button
variant="secondary"
size="lg"
className="w-full"
onClick={onClose}
>
Batal Hapus
</Button>
<Button
variant="primary"
size="lg"
className="w-full bg-danger-500 hover:bg-danger-600"
onClick={onConfirm}
>
Hapus Role
</Button>
</Modal.Content>
</Modal>
);
};
export default ModalDeletePermission;
@@ -1,111 +0,0 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
import { useConfirmItem } from '../_hook/use-item';
interface IModalUpdatePermission {
isOpen: boolean;
onClose: () => void;
handleUpdate?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
}
const ModalUpdatePermission = ({
isOpen,
onClose,
resetStep,
handleUpdate,
}: IModalUpdatePermission) => {
const { onConfirm } = useConfirmItem(onClose, resetStep, handleUpdate, {
success: 'Perubahan roles berhasil dilakukan',
error: 'Perubahan roles gagal dilakukan',
});
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-0 text-center"
isOpen={isOpen}
onClose={onClose}
disableEscapeKeyDown={true}
>
<Modal.Header>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Update Roles
</h2>
</Modal.Header>
<Modal.Content className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<InputField
label="Nama Role"
name="name"
type="text"
placeholder="Masukkan Nama Role"
size="lg"
className="w-full"
/>
</div>
<div className="flex flex-col gap-4 items-start overflow-auto">
<span className="text-p3 font-medium text-neutral-800 sticky left-0">
Permissions
</span>
<div className="flex gap-x-6 overflow-x-scroll">
{['Gacha Items', 'Gacha Roll', 'Roll', 'Users', 'Gacha Claim'].map(
(title) => (
<div
key={title}
className="flex flex-col gap-4 select-none text-label2 font-medium text-neutral-900 "
>
<span className="text-nowrap text-label1">{title}</span>
<div className="flex gap-[8px] items-center">
<input
type="checkbox"
id={`${title}-all`}
className="rounded"
/>
<label htmlFor={`${title}-all`} className="text-nowrap">
Check All
</label>
</div>
<hr className="border-blue-200" />
<div className="flex flex-col items-start gap-4 mb-4">
<div className="flex gap-[8px] items-center">
<input type="checkbox" id={`${title}-read`} />
<label htmlFor={`${title}-read`}>Read</label>
</div>
<div className="flex gap-[8px] items-center">
<input type="checkbox" id={`${title}-create`} />
<label htmlFor={`${title}-create`}>Create</label>
</div>
<div className="flex gap-[8px] items-center">
<input type="checkbox" id={`${title}-update`} />
<label htmlFor={`${title}-update`}>Update</label>
</div>
<div className="flex gap-[8px] items-center">
<input type="checkbox" id={`${title}-delete`} />
<label htmlFor={`${title}-delete`}>Delete</label>
</div>
</div>
</div>
)
)}
</div>
</div>
<Button
variant="primary"
size="lg"
className="w-full"
type="submit"
onClick={onConfirm}
>
Update Role
</Button>
</Modal.Content>
</Modal>
);
};
export default ModalUpdatePermission;
@@ -1,203 +0,0 @@
import { FC, Fragment, ReactElement, useState } from 'react';
import {
SearchOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import ModalAddRole from './_components/modal-add-role';
import ModalUpdateRole from './_components/modal-update-role';
import ModalDeleteRole from './_components/modal-delete-role';
import { useQueryState } from '@imphnen-frontend-service/utils';
import React from 'react';
interface Role {
id: number;
name: string;
}
const mockData: Role[] = [
{ id: 1, name: 'Admin' },
{ id: 2, name: 'Admin Pembayaran' },
{ id: 3, name: 'Staff' },
{ id: 4, name: 'Staff Aktivasi User' },
{ id: 5, name: 'User' },
];
export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false);
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const columns: ColumnDef<Role>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'ID',
accessorKey: 'id',
},
{
header: 'Roles Name',
accessorKey: 'name',
},
{
header: 'Action',
cell: () => (
<div className="flex gap-[8px]">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalUpdateItem(true);
}}
className="flex items-center gap-2"
>
<EditOutlined /> Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalDeleteItem(true);
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
</div>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Roles</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama roles"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => {
setShowModalAddItem(true);
}}
>
<PlusOutlined />
Tambah Role
</Button>
</div>
</div>
<DataTable
data={mockData}
columns={columns}
pageSize={9}
table={table}
/>
</section>
</main>
<ModalAddRole
currentStep={currentStep}
isOpen={showModalAddItem}
onClose={() => setShowModalAddItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
<ModalUpdateRole
isOpen={showModalUpdateItem}
onClose={() => setShowModalUpdateItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
<ModalDeleteRole
isOpen={showModalDeleteItem}
onClose={() => setShowModalDeleteItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
/>
</Fragment>
);
};
export default Components;
@@ -1,114 +0,0 @@
import { Icon } from "@iconify/react";
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
import { Modal } from "@imphnen-frontend-service/ui/molecules";
import { cn, For } from "@imphnen-frontend-service/utils";
import { FC } from "react";
const TOPICS = [
{ id: 2, icon: '🏢', name: 'Industry Insight' },
{ id: 4, icon: '🖥️', name: 'Basic IT' },
]
const placeholder = `Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.
Saat ini, saya tertarik untuk mengejar __. Tujuan saya untuk sesi ini adalah __.
Saya ingin tahu secara khusus tentang ___.
1.Pertanyaan Anda
2. ...
3. ...`
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
type ModalProps = {
open: boolean
setOpen: (open: boolean) => void
}
export const ModalDetailSession: FC<ModalProps> = ({ open, setOpen }) => {
return (
<Modal
isOpen={open}
onClose={() => setOpen(false)}
className="xl:max-w-[64rem] bg-white px-10 py-9"
closeButtonClassName="hidden"
>
<div>
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Detail Sesi
</h1>
<div className="grid grid-cols-9 px-9 py-7 border rounded-md gap-12 mb-10">
<div className="col-span-4 flex items-center gap-x-12">
<div>
<h2 className="text-p2 font-semibold text-primary-500 mb-4">Mentor</h2>
<div>
<p className="text-p3 font-semibold mb-2.5">Muhammad Firdaus Oi Oi Oi, S.H., M.H.</p>
<p className="text-neutral-600">UI Designer at Oray orayan Studios</p>
</div>
</div>
<Icon icon="ph:arrow-right" className="text-9xl text-primary-500" />
</div>
<div className="col-span-5 flex items-center gap-12">
<div>
<h2 className="text-p2 font-semibold text-primary-500 mb-4">Mentee</h2>
<div>
<p className="text-p3 font-semibold mb-2.5">Muhammad Firdaus Oi Oi Oi, S.H., M.H.</p>
<p className="text-neutral-600">UI Designer at Oray orayan Studios</p>
</div>
</div>
<div>
<h2 className="text-p2 font-semibold text-neutral-700 mb-4">Status</h2>
<div className="py-2 px-6 rounded-md text-center bg-success-200 text-success-500 font-semibold">
Finished
</div>
</div>
</div>
</div>
<div>
<h1 className="text-p3 font-medium mb-2.5">Topics</h1>
<div className="p-5 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-2.5 mb-8">
<For data={TOPICS}>
{(item, index) => (
<div
key={index}
className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow font-medium"
>
<span>{item.icon} </span>
<span>{item.name}</span>
</div>
)}
</For>
</div>
<div className="grid gap-2.5 md:grid-cols-2 md:gap-5">
<div>
<label className={labelClass}>Tanggal</label>
<Input type="date" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Waktu</label>
<Input type="time" className="min-w-full w-full" />
</div>
<div className="relative md:col-span-full">
<label className={labelClass}>Lokasi</label>
<Select className="min-w-full w-full">
<option value="online">Online</option>
<option value="offline">Offline</option>
</Select>
</div>
<div className="md:col-span-full">
<label className={labelClass}>Pertanyaan Untuk Senpai</label>
<Textarea
className="min-w-full w-full h-40"
placeholder={placeholder}
/>
</div>
</div>
</div>
</div>
</Modal>
)
}
@@ -1,168 +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 } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { ReactElement, useState } from "react";
import { ModalDetailSession } from "./_components/modal/detail";
type SessionStatus = 'ongoing' | 'finished';
interface SessionType {
id: string
mentorName: string
menteeName: string
datetime: number
status: SessionStatus
}
const mockData: SessionType[] = Array.from({ length: 90 }, (_, i) => ({
id: `DS-${i + 1}`,
mentorName: 'Ahmad Wijuana',
menteeName: 'Sofia Wijuana',
datetime: new Date().getTime(),
status: i % 2 === 0 ? 'ongoing' : 'finished',
}))
export default function Components(): ReactElement {
const [openDetail, setOpenDetail] = useState(false);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<SessionType>[] = [
{
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: 'id',
header: 'ID Sesi',
accessorKey: 'id',
},
{
id: 'mentorName',
header: 'Nama Mentor',
accessorKey: 'name',
},
{
id: 'menteeName',
header: 'Nama Mentee',
accessorKey: 'name',
},
{
id: 'datetime',
header: 'Waktu',
accessorKey: 'datetime',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<SessionStatus, string> = {
ongoing: 'bg-warning-200 text-warning-700',
finished: 'bg-success-200 text-success-500',
};
const statusText: Record<SessionStatus, string> = {
ongoing: 'On Going',
finished: 'Finished',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn("w-52") },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setOpenDetail(true);
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Cek Detail
</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">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Session Management</h1>
<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 lengkap"
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="finished">Finished</option>
<option value="ongoing">On Going</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
<ModalDetailSession open={openDetail} setOpen={setOpenDetail} />
</BackofficeWrapper>
)
}
@@ -1,53 +0,0 @@
import { BackofficeWrapper } from "@imphnen-frontend-service/ui/organisms"
import { cn, For } from "@imphnen-frontend-service/utils"
import { useState } from "react"
import { GeneralSettings } from "./_components/general"
import { UserRolesPermission } from "./_components/user-roles-permission"
import { NotificationSettings } from "./_components/notification"
import { SecuritySettings } from "./_components/security"
import { PaymentSettings } from "./_components/payment"
const TABS = {
general: "General Settings",
userRolePermissions: "User Roles & Permissions",
notification: "Notification Settings",
security: "Security",
payment: "Payment",
} as const
type Tabs = typeof TABS[keyof typeof TABS]
export default function Components(): React.ReactElement {
const [activeTab, setActiveTab] = useState<Tabs>(TABS.general)
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Settings</h1>
<div className="flex items-start gap-x-8">
<div className="w-64 bg-white p-2.5 shadow space-y-2 rounded-md">
<For data={Object.values(TABS)}>
{(tab) => (
<button
key={tab}
className={cn(
"px-4 py-3 w-full text-left font-medium rounded-md text-neutral-400 cursor-pointer select-none hover:bg-primary-100",
activeTab === tab && "bg-primary-500 text-white hover:bg-primary-600"
)}
onClick={() => setActiveTab(tab)}
>
{tab}
</button>
)}
</For>
</div>
<div className="bg-white px-8 py-6 shadow space-y-2 rounded-md flex-1">
{activeTab === TABS.general && <GeneralSettings />}
{activeTab === TABS.userRolePermissions && <UserRolesPermission />}
{activeTab === TABS.notification && <NotificationSettings />}
{activeTab === TABS.security && <SecuritySettings />}
{activeTab === TABS.payment && <PaymentSettings />}
</div>
</div>
</BackofficeWrapper>
)
}
@@ -1,214 +0,0 @@
import * as React from 'react';
import { FC, Fragment, ReactElement, useState } from 'react';
import {
FilterOutlined,
SearchOutlined,
AuditOutlined,
} from '@ant-design/icons';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
useReactTable,
RowSelectionState,
} from '@tanstack/react-table';
import ModalValidate from './_components/modal-validate';
type TransactionStatus = 'valid' | 'invalid' | 'unchecked';
interface Transaction {
id: number;
name: string;
transactionNumber: string;
status: TransactionStatus;
}
// Mock data for transactions
const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
id: i + 1,
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
transactionNumber: '25D2133Y9AFYBD',
status: (i % 3 === 0
? 'invalid'
: i % 5 === 0
? 'unchecked'
: 'valid') as TransactionStatus,
}));
export const Components: FC = (): ReactElement => {
const [showModalValidate, setShowModalValidate] = useState(false);
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = useState(false);
const validationOptions = [
{ id: 'option1', value: 'unchecked', label: 'Unchecked' },
{ id: 'option2', value: 'valid', label: 'Valid' },
{ id: 'option3', value: 'invalid', label: 'Invalid' },
];
const columns: ColumnDef<Transaction>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Nama Lengkap',
accessorKey: 'name',
},
{
header: 'Nomor Transaksi',
accessorKey: 'transactionNumber',
},
{
header: 'Order Valid?',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<TransactionStatus, string> = {
valid: 'bg-success-200 text-success-500',
invalid: 'bg-danger-200 text-danger-500',
unchecked: 'bg-warning-200 text-warning-900',
};
const statusText: Record<TransactionStatus, string> = {
valid: 'Valid',
invalid: 'Invalid',
unchecked: 'Unchecked',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowModalValidate(true);
}}
className="flex items-center gap-2 w-full"
>
<AuditOutlined className="text-[16px]" /> Update
</Button>
),
},
];
const table = useReactTable({
data: mockTransactions,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockTransactions.length / pagination.pageSize),
manualPagination: false,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
{/* Header */}
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Validasi Transaksi</h1>
</header>
{/* Account Table Section */}
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
{/* Search and Filter */}
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex items-center gap-3"
onClick={() => setShowFilter(!showFilter)}
>
<FilterOutlined />
Filters
</Button>
{showFilter && (
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
<Filter
options={validationOptions}
onClose={() => setShowFilter(false)}
onFilterChange={(value) => {
console.log('Selected filter:', value);
// Filter logic di sini
}}
/>
</div>
)}
</div>
</div>
{/* Table */}
<DataTable data={mockTransactions} columns={columns} table={table} />
</section>
</main>
<ModalValidate
isOpen={showModalValidate}
onClose={() => setShowModalValidate(false)}
handleValid={() => {
console.log('Action ketika user klik Valid');
}}
handleInvalid={() => {
console.log('Action ketika user klik Tidak Valid');
}}
/>
</Fragment>
);
};
export default Components;
@@ -1,55 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { Modal } from "@imphnen-frontend-service/ui/molecules";
import { FC } from "react";
type ModalDeleteProps = {
open: boolean
setOpen: (open: boolean) => void
hadnleDelete?: () => void
}
export const ModalDelete: FC<ModalDeleteProps> = ({ open, setOpen, hadnleDelete }) => {
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
isOpen={open}
onClose={() => setOpen(false)}
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">
Hapus Akun
</h2>
<p className="text-p3 text-neutral-400">
Apakah kamu yakin untuk menghapus akun ini?
</p>
</div>
</Modal.Header>
<Modal.Content className="flex gap-4">
<Button
variant="bordered"
size="lg"
className="w-full border-danger-500 text-danger-500 hover:bg-danger-50 hover:text-danger-600 hover:border-danger-600"
onClick={() => setOpen(false)}
>
Batal
</Button>
<Button
variant="danger"
size="lg"
className="w-full"
onClick={() => hadnleDelete?.()}
>
Hapus Akun
</Button>
</Modal.Content>
</Modal>
)
}
@@ -1,86 +0,0 @@
import { Button, Input } from "@imphnen-frontend-service/ui/atoms";
import { cn } from "@imphnen-frontend-service/utils";
import { FC, useState } from "react";
import { ModalDelete } from "../delete";
import { ModalProps } from "../type";
import { ModalSuspendOrBan } from "../suspend-or-ban";
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const AccountProfile: FC<ModalProps> = ({ setOpen }) => {
const [openDelete, setOpenDelete] = useState(false)
const [openSuspendOrBan, setOpenSuspendOrBan] = useState(false)
return (
<div>
<div className="flex items-center gap-x-8 mb-8">
<div className="size-[100px] rounded-full overflow-hidden">
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="w-full object-cover" />
</div>
<div>
<Button type="button" size="sm" variant="bordered" className="mb-4">
Upload Foto
</Button>
<p className="text-neutral-400 font-medium">
Setidaknya rekomendasi ukuran 240x240 px. <br />
.jpg, .jpeg, .png diperbolehkan
</p>
</div>
</div>
<div>
<div className="mb-8">
<h1 className="mb-7 text-p2 font-semibold">Informasi Pribadi</h1>
<div className="grid grid-cols-2 gap-8">
<div>
<label className={labelClass}>Nama Depan</label>
<Input type="text" className="min-w-full w-full" placeholder="Nama Depan" />
</div>
<div>
<label className={labelClass}>Nama Belakang</label>
<Input type="text" className="min-w-full w-full" placeholder="Nama Belakang" />
</div>
<div>
<label className={labelClass}>Email</label>
<Input type="email" className="min-w-full w-full" placeholder="Email" />
</div>
<div>
<label className={labelClass}>Nomor Telepon</label>
<Input type="text" className="min-w-full w-full" placeholder="+62 81234567890" />
</div>
</div>
</div>
<div>
<h1 className="mb-7 text-p2 font-semibold">Status Akun</h1>
<div className="flex items-center gap-5">
<Button
type="button"
size="sm"
variant="bordered"
className="border-danger-500 text-danger-500"
onClick={() => setOpenSuspendOrBan(true)}
>
Suspend/Ban
</Button>
<Button type="button" size="sm" variant="danger" onClick={() => setOpenDelete(true)}>
Delete Akun
</Button>
</div>
</div>
<div className="flex justify-end gap-x-5">
<Button type="button" variant="bordered" onClick={() => setOpen(false)}>
Batal
</Button>
<Button type="button" disabled>
Simpan
</Button>
</div>
</div>
<ModalDelete open={openDelete} setOpen={setOpenDelete} />
<ModalSuspendOrBan open={openSuspendOrBan} setOpen={setOpenSuspendOrBan} />
</div>
)
}
@@ -1,110 +0,0 @@
import { SearchOutlined } from "@ant-design/icons"
import { Input, Select } from "@imphnen-frontend-service/ui/atoms"
import { DataTable } from "@imphnen-frontend-service/ui/organisms"
import { cn } from "@imphnen-frontend-service/utils"
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table"
import { FC, useState } from "react"
import dayjs from "dayjs"
interface ActivityLogType {
id: number
date: string
menu: string
activity: string
}
const mockData: ActivityLogType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
date: new Date().toISOString(),
menu: 'Mentoring',
activity: 'Mentoring Session',
}))
export const ActivityLog: FC = () => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
const columns: ColumnDef<ActivityLogType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-16") },
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: 'date',
header: 'Waktu',
accessorKey: 'date',
cell: (info) => dayjs(info.row.original.date).format('DD MMMM YYYY, HH:mm WIB'),
},
{
id: 'menu',
header: 'Menu',
accessorKey: 'menu',
},
{
id: 'activity',
header: 'Activity',
accessorKey: 'activity',
}
]
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 (
<div className="p-8 shadow rounded-lg">
<div className="flex justify-between items-center gap-5 mb-9">
<div className="relative w-1/2">
<Input
placeholder="Cari aktivitas"
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="w-1/4">
<Input type="date" className="min-w-full w-full" />
</div>
<Select>
<option selected disabled>Menu</option>
<option value="profle">Profile</option>
<option value="profle-2">Profile</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</div>
)
}
@@ -1,89 +0,0 @@
import { Icon } from "@iconify/react"
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { For } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const SOCIAL_LINKS = [
{ icon: <Icon icon="mdi:linkedin" className="text-2xl" />, url: "https://linkedin.com", label: "LinkedIn" },
{ icon: <Icon icon="mdi:github" className="text-2xl" />, url: "https://github.com", label: "Github" },
{ icon: <Icon icon="mingcute:meta-line" className="text-2xl" />, url: "https://facebook.com", label: "Facebook (Meta)" },
{ icon: <Icon icon="mdi:stack-overflow" className="text-2xl" />, url: "https://stackoverflow.com", label: "Stack Overflow" }
]
export const DetailProfile: FC = () => {
return (
<div>
<div className="shadow rounded-lg p-8 mb-7">
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-x-6">
<div className="size-[54px] rounded-full overflow-hidden">
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="w-full object-cover" />
</div>
<div>
<h1 className="text-p2 font-semibold text-neutral-800">Muhammad Firdaus Oiwobo</h1>
<p className="text-p3 font-medium text-neutral-600">Mentor</p>
</div>
</div>
<Button type="button" variant="text" className="bg-primary-100">
Actively Seeking Job
</Button>
</div>
<div className="flex items-center gap-5">
<For data={SOCIAL_LINKS}>
{({ icon, url, label }) => (
<a key={url} href={url} target="_blank" rel="noreferrer">
<Button type="button" size="sm" className="flex items-center gap-x-2">
{icon}
{label}
</Button>
</a>
)}
</For>
</div>
</div>
<div className="grid grid-cols-2 gap-7">
<div className="text-pretty px-8 py-10 rounded-lg shadow h-max">
<h1 className="text-p2 font-semibold text-neutral-800 mb-6">Description</h1>
<p className="text-p3 font-medium text-neutral-600">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
</div>
<div className="px-8 py-10 rounded-lg shadow h-max">
<h1 className="text-p2 font-semibold text-neutral-800 mb-6">Personal Informations</h1>
<div className="grid gap-6">
<div className="flex items-center gap-x-5">
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
<Icon icon="ic:outline-mail" className="text-3xl" />
</div>
<div className="text-p3">
<p className="text-neutral-800 font-semibold">rzalaxib23@gmail.com</p>
<p className="text-neutral-600 font-medium">Email Address</p>
</div>
</div>
<div className="flex items-center gap-x-5">
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
<Icon icon="cil:phone" className="text-3xl" />
</div>
<div className="text-p3">
<p className="text-neutral-800 font-semibold">+62 888 8888 8888</p>
<p className="text-neutral-600 font-medium">Phone Number</p>
</div>
</div>
<div className="flex items-center gap-x-5">
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
<Icon icon="ion:location-outline" className="text-3xl" />
</div>
<div className="text-p3">
<p className="text-neutral-800 font-semibold">Jl. Mergosari, Kec. Suryakencana, Banjaran</p>
<p className="text-neutral-600 font-medium">Location</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -1,68 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { Modal } from "@imphnen-frontend-service/ui/molecules"
import { cn, For, Show } from "@imphnen-frontend-service/utils"
import { FC, useEffect, useState } from "react"
import { AccountProfile } from "./account-profile"
import { DetailProfile } from "./detail-profile"
import { ActivityLog } from "./activity-log"
import { ModalDetailUserProps } from "./type"
const TABS = {
account: 'account profile',
detail: 'detail profile',
activity: 'activity logs',
} as const
type TabType = typeof TABS[keyof typeof TABS]
export const ModalDetailUser: FC<ModalDetailUserProps> = ({
open,
setOpen,
}) => {
const [activeTab, setActiveTab] = useState<TabType>(TABS.account)
useEffect(() => {
if (!open) setActiveTab(TABS.account)
}, [open])
return (
<Modal
isOpen={open}
onClose={() => setOpen(false)}
className="xl:max-w-[84rem] bg-white px-10 py-9"
closeButtonClassName="hidden"
>
<div>
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Detail - Muhammad Firdaus Oiwobo
</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md w-max mb-8">
<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>
<Show condition={activeTab === TABS.account}>
<AccountProfile open={open} setOpen={setOpen} />
</Show>
<Show condition={activeTab === TABS.detail}>
<DetailProfile />
</Show>
<Show condition={activeTab === TABS.activity}>
<ActivityLog />
</Show>
</div>
</div>
</Modal>
)
}
@@ -1,5 +0,0 @@
import { ModalProps } from "../type";
export interface ModalDetailUserProps extends ModalProps {
userId?: number | null
}
@@ -1,42 +0,0 @@
import { FC } from "react";
import { ModalProps } from "../type";
import { Modal } from "@imphnen-frontend-service/ui/molecules";
import { cn } from "@imphnen-frontend-service/utils";
import { Button, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const ModalSuspendOrBan: FC<ModalProps> = ({ open, setOpen }) => {
return (
<Modal
isOpen={open}
onClose={() => setOpen(false)}
className="bg-white px-10 py-9 xl:max-w-[32rem]"
closeButtonClassName="hidden"
>
<div>
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Suspend/Ban
</h1>
<div className="space-y-6">
<div>
<label className={labelClass}>Suspend/Ban</label>
<Select defaultValue="suspend" className="w-full">
<option value="suspend">Suspend</option>
<option value="banned">Banned</option>
</Select>
</div>
<div>
<label className={labelClass}>Alasan</label>
<Textarea placeholder="Masukkan alasan suspend/ban" className="w-full h-40" />
</div>
<Button type="button" className="w-full">
Selesai
</Button>
</div>
</div>
</Modal>
)
}
@@ -1,4 +0,0 @@
export interface ModalProps {
open: boolean
setOpen: (open: boolean) => void
}
@@ -1,206 +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';
import { ModalDetailUser } from './_components/modal/detail';
type UserStatus = 'active' | 'inactive';
interface UserType {
id: number;
name: string;
email: string;
rating: number;
status: UserStatus;
}
const mockData: UserType[] = 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 ? 'active' : 'inactive',
}));
export default function Components(): ReactElement {
const TABS = ['mentor', 'mentee'] as const;
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor');
const [showDetail, setShowDetail] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<UserType>[] = [
{
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<UserStatus, string> = {
active: 'bg-success-200 text-success-500',
inactive: 'bg-danger-200 text-danger-500',
};
const statusText: Record<UserStatus, string> = {
active: 'Active',
inactive: 'Inactive',
};
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();
setSelectedUserId(row.original.id);
setShowDetail(true);
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Lihat Detail & Action
</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 mb-8">
User Management
</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
<For data={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 lengkap"
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>
<ModalDetailUser
open={showDetail}
setOpen={setShowDetail}
userId={selectedUserId}
/>
</BackofficeWrapper>
);
}
@@ -1,55 +0,0 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useLogin } from './_hooks/use-login';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
export const Components: FC = (): ReactElement => {
const { form, onSubmit, isLoading } = useLogin();
return (
<div className="flex justify-center items-center min-h-screen">
<div className="bg-white border border-primary-200 shadow-lg p-[60px] text-center flex flex-col justify-items-stretch gap-8 rounded-2xl">
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
<h1 className="text-primary-500 text-p1 font-semibold">
Welcome to IMPHNEN Backoffice
</h1>
<form onSubmit={onSubmit} className="flex flex-col gap-8">
<ControlledInputField
control={form.control}
label="Email"
placeholder="Masukkan Email"
type="email"
name="email"
size="lg"
className="w-full"
disabled={isLoading}
/>
<ControlledInputField
control={form.control}
label="Password"
placeholder="Masukkan Password"
type="password"
name="password"
size="lg"
className="w-full"
disabled={isLoading}
/>
<Button
disabled={
isLoading ||
form.formState.isValidating ||
!form.formState.isValid
}
type="submit"
size="md"
className="w-full"
>
{isLoading ? 'Loading...' : 'Login'}
</Button>
</form>
</div>
</div>
);
};
export default Components;
@@ -1,12 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
-23
View File
@@ -1,23 +0,0 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
<h2 className="text-3xl font-semibold text-gray-900 dark:text-gray-300 mb-4">
Page Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
The page you are looking for doesn't exist or has been moved.
</p>
<Link
to="/hackathon-dashboard"
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Go Back Home
</Link>
</div>
</div>
);
}
-29
View File
@@ -1,29 +0,0 @@
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
export default function ErrorPage() {
const error = useRouteError();
let errorMessage: string;
if (isRouteErrorResponse(error)) {
errorMessage = error.statusText;
} else if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
} else {
console.error(error);
errorMessage = 'Unknown error';
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-6xl font-bold text-red-600 mb-4">Oops!</h1>
<p className="text-xl text-gray-700 mb-2">
Sorry, an unexpected error has occurred.
</p>
<p className="text-gray-500 italic">{errorMessage}</p>
</div>
</div>
);
}
@@ -24,12 +24,10 @@ export const CityFilterSelect: FC<CityFilterSelectProps> = ({
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Filter cities based on search query
const filteredCities = INDONESIAN_CITIES.filter((city) => const filteredCities = INDONESIAN_CITIES.filter((city) =>
city.toLowerCase().includes(searchQuery.toLowerCase()) city.toLowerCase().includes(searchQuery.toLowerCase())
); );
// Close dropdown when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if ( if (
@@ -102,7 +100,6 @@ export const CityFilterSelect: FC<CityFilterSelectProps> = ({
{isOpen && ( {isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-60 overflow-y-auto"> <div className="absolute z-50 w-full mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{/* All Cities Option */}
<div <div
onClick={() => handleSelectCity('all')} onClick={() => handleSelectCity('all')}
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 border-b border-neutral-100 ${ className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 border-b border-neutral-100 ${
@@ -114,7 +111,6 @@ export const CityFilterSelect: FC<CityFilterSelectProps> = ({
{allOptionLabel} {allOptionLabel}
</div> </div>
{/* Filtered Cities */}
{filteredCities.length > 0 ? ( {filteredCities.length > 0 ? (
<div className="py-1"> <div className="py-1">
{filteredCities.slice(0, 100).map((city) => ( {filteredCities.slice(0, 100).map((city) => (
@@ -0,0 +1,79 @@
import * as React from 'react';
import type { Row, Table } from '@tanstack/react-table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Checkbox,
} from '@imphnen-frontend-service/ui/atoms';
/** Renders a header checkbox that toggles all rows (with indeterminate support). */
export function SelectAllCheckbox<T>({ table }: { table: Table<T> }) {
return (
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={(v) =>
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
}
aria-label="Select all"
/>
);
}
/** Per-row checkbox. */
export function RowSelectCheckbox<T>({ row }: { row: Row<T> }) {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="Select row"
/>
);
}
type DeleteConfirmProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
title?: string;
description?: string;
};
export function DeleteConfirmDialog({
open,
onOpenChange,
onConfirm,
title = 'Hapus data ini?',
description = 'Tindakan ini tidak dapat dibatalkan. Data akan dihapus permanen.',
}: DeleteConfirmProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Batal</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Hapus
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+233
View File
@@ -0,0 +1,233 @@
'use client';
import { FC, ReactElement, useState } from 'react';
import { useLocation, useNavigate } from '@tanstack/react-router';
import {
LayoutDashboard,
Users,
UsersRound,
ClipboardCheck,
BookOpen,
MessageSquare,
MessageCircle,
Calendar,
CalendarClock,
Settings,
BarChart3,
RefreshCcw,
Inbox,
UserCog,
UserPlus,
ShieldCheck,
KeyRound,
User,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
type MenuLink = {
label: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
};
type MenuGroup = {
label: string;
icon: React.ComponentType<{ className?: string }>;
children: MenuLink[];
};
type MenuItem = MenuLink | MenuGroup;
const MENUS: MenuItem[] = [
{
label: 'Hackathon',
icon: BarChart3,
children: [
{ label: 'Dashboard', href: '/hackathon-dashboard', icon: LayoutDashboard },
{ label: 'Users', href: '/hackathon-users', icon: Users },
{ label: 'Teams', href: '/hackathon-teams', icon: UsersRound },
{ label: 'Submissions', href: '/hackathon-submissions', icon: ClipboardCheck },
],
},
{
label: 'Dimentorin',
icon: BookOpen,
children: [
{ label: 'Dashboard', href: '/dashboard-dimentorin', icon: LayoutDashboard },
{ label: 'Users', href: '/users-dimentorin', icon: UserCog },
{ label: 'Session', href: '/session-dimentorin', icon: CalendarClock },
{ label: 'Content & Roadmap', href: '/roadmap-dimentorin', icon: BookOpen },
{ label: 'Feedback & Review', href: '/feedback-review-dimentorin', icon: MessageSquare },
{ label: 'Settings', href: '/settings-dimentorin', icon: Settings },
],
},
{
label: 'Gacha',
icon: RefreshCcw,
children: [
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ label: 'Gacha Roll', href: '/gacha-roll', icon: RefreshCcw },
{ label: 'Validasi Transaksi', href: '/transactions', icon: ClipboardCheck },
{ label: 'Data Pengiriman', href: '/prizes', icon: Inbox },
],
},
{
label: 'CMS',
icon: BookOpen,
children: [
{ label: 'Events', href: '/cms-events', icon: Calendar },
{ label: 'Testimonials', href: '/cms-testimonials', icon: MessageCircle },
],
},
];
const FLAT_MENUS: MenuLink[] = [
{ label: 'Permissions', href: '/permissions', icon: ShieldCheck },
{ label: 'Roles', href: '/roles', icon: KeyRound },
{ label: 'Data Akun', href: '/accounts', icon: User },
];
const isMenuGroup = (item: MenuItem): item is MenuGroup =>
(item as MenuGroup).children !== undefined;
export const BackofficeSidebar: FC = (): ReactElement => {
const location = useLocation();
const navigate = useNavigate();
const isActive = (path: string) => {
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin') {
return false;
}
return location.pathname.includes(path);
};
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
MENUS.forEach((menu) => {
if (
isMenuGroup(menu) &&
menu.children.some((child) => isActive(child.href))
) {
initial[menu.label] = true;
}
});
return initial;
});
const toggleGroup = (label: string) =>
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
return (
<Sidebar collapsible="offcanvas" variant="inset">
<SidebarHeader>
<div className="flex items-center justify-center px-2 py-3">
<img
src="/logos/simple.svg"
alt="IMPHNEN Logo"
className="h-10 w-auto"
/>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{MENUS.map((menu) => {
const GroupIcon = menu.icon;
const open = !!openGroups[menu.label];
const groupHasActive =
isMenuGroup(menu) &&
menu.children.some((c) => isActive(c.href));
return (
<SidebarMenuItem key={menu.label}>
<SidebarMenuButton
onClick={() => toggleGroup(menu.label)}
isActive={groupHasActive && !open}
className="justify-between"
>
<span className="flex items-center gap-2">
<GroupIcon className="size-4" />
<span>{menu.label}</span>
</span>
{open ? (
<ChevronDown className="size-3.5 opacity-60" />
) : (
<ChevronRight className="size-3.5 opacity-60" />
)}
</SidebarMenuButton>
{isMenuGroup(menu) && open && (
<SidebarMenuSub>
{menu.children.map((child) => {
const ChildIcon = child.icon;
return (
<SidebarMenuSubItem key={child.href}>
<SidebarMenuSubButton
asChild
isActive={isActive(child.href)}
>
<button
type="button"
onClick={() =>
navigate({ to: child.href })
}
className={cn(
'w-full text-left',
)}
>
<ChildIcon className="size-4" />
<span>{child.label}</span>
</button>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
)}
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>System</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{FLAT_MENUS.map((menu) => {
const Icon = menu.icon;
return (
<SidebarMenuItem key={menu.href}>
<SidebarMenuButton
isActive={isActive(menu.href)}
onClick={() => navigate({ to: menu.href })}
>
<Icon className="size-4" />
<span>{menu.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
);
};
+40
View File
@@ -1,5 +1,6 @@
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css';
@source "../../../libs/ui/**/*.{ts,tsx}"; @source "../../../libs/ui/**/*.{ts,tsx}";
@theme { @theme {
@@ -96,6 +97,45 @@
/* ~10px */ /* ~10px */
--text-label3: 0.677rem; --text-label3: 0.677rem;
/* ~8px */ /* ~8px */
/* shadcn/ui semantic tokens — aliased to existing palette (keep colors consistent) */
--color-background: #ffffff;
--color-foreground: #3d3d3d;
--color-card: #ffffff;
--color-card-foreground: #3d3d3d;
--color-popover: #ffffff;
--color-popover-foreground: #3d3d3d;
--color-primary: #23a1eb;
--color-primary-foreground: #ffffff;
--color-secondary: #e7e7e7;
--color-secondary-foreground: #3d3d3d;
--color-muted: #f6f6f6;
--color-muted-foreground: #6d6d6d;
--color-accent: #e1f0fd;
--color-accent-foreground: #085f9c;
--color-destructive: #ff5242;
--color-destructive-foreground: #ffffff;
--color-border: #d1d1d1;
--color-input: #d1d1d1;
--color-ring: #3eb0f2;
--color-sidebar: #ffffff;
--color-sidebar-foreground: #4f4f4f;
--color-sidebar-primary: #23a1eb;
--color-sidebar-primary-foreground: #ffffff;
--color-sidebar-accent: #f0f8ff;
--color-sidebar-accent-foreground: #085f9c;
--color-sidebar-border: #e7e7e7;
--color-sidebar-ring: #3eb0f2;
--color-chart-1: #23a1eb;
--color-chart-2: #35ba43;
--color-chart-3: #04acf3;
--color-chart-4: #ffed27;
--color-chart-5: #ff5242;
--radius: 8px;
--font-sans: 'Bai Jamjuree', sans-serif;
} }
@layer base { @layer base {
+16 -33
View File
@@ -1,42 +1,25 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client'
import { middleware } from './middleware'; import { RouterProvider, createRouter } from '@tanstack/react-router'
import { StrictMode } from 'react'; import { QueryProvider } from '@imphnen-frontend-service/utils'
import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router'; import { Toaster } from 'sonner'
import { import { routeTree } from './routeTree.gen'
add404PageToRoutesChildren, import './index.css'
addErrorElementToRoutes,
convertPagesToRoute,
QueryProvider,
} from '@imphnen-frontend-service/utils';
import { Toaster } from 'sonner';
import './index.css';
const files = import.meta.glob('./app/**/*(page|layout).tsx'); const router = createRouter({ routeTree })
const errorFiles = import.meta.glob('./app/**/*error.tsx');
const notFoundFiles = import.meta.glob('./app/**/*404.tsx');
const loadingFiles = import.meta.glob('./app/**/*loading.tsx');
const routes = convertPagesToRoute(files, loadingFiles) as RouteObject; declare module '@tanstack/react-router' {
addErrorElementToRoutes(errorFiles, routes); interface Register {
add404PageToRoutesChildren(notFoundFiles, routes); router: typeof router
}
}
const router = createBrowserRouter([ const rootElement = document.getElementById('root')
{
...routes,
loader: middleware,
shouldRevalidate: () => true,
},
]);
const rootElement = document.getElementById('root'); if (!rootElement) throw new Error('Failed to find the root element')
if (!rootElement) throw new Error('Failed to find the root element');
createRoot(rootElement).render( createRoot(rootElement).render(
<StrictMode>
<QueryProvider> <QueryProvider>
<Toaster position="top-right" />
<RouterProvider router={router} /> <RouterProvider router={router} />
<Toaster position="top-right" />
</QueryProvider> </QueryProvider>
</StrictMode> )
);
-108
View File
@@ -1,108 +0,0 @@
import {
PERMISSIONS,
SessionToken,
SessionUser,
} from '@imphnen-frontend-service/utils';
import { LoaderFunctionArgs, redirect } from 'react-router';
const mappingPublicRoutes = [
'/auth/login',
'/auth/forgot',
'/auth/new-password',
];
const mappingRoutePermissions = [
{
path: '/dashboard',
permissions: [],
},
{
path: '/users',
permissions: [PERMISSIONS.USERS.READ_LIST],
},
{
path: '/users/create',
permissions: [PERMISSIONS.USERS.CREATE],
},
{
path: '/users/update',
permissions: [PERMISSIONS.USERS.UPDATE],
},
{
path: '/users/detail',
permissions: [PERMISSIONS.USERS.READ_DETAIL],
},
{
path: '/roles',
permissions: [PERMISSIONS.ROLES.READ_LIST],
},
{
path: '/roles/create',
permissions: [PERMISSIONS.ROLES.CREATE],
},
{
path: '/roles/update',
permissions: [PERMISSIONS.ROLES.UPDATE],
},
{
path: '/roles/detail',
permissions: [PERMISSIONS.ROLES.READ_DETAIL],
},
{
path: '/permissions',
permissions: [PERMISSIONS.PERMISSIONS.READ_LIST],
},
{
path: '/permissions/create',
permissions: [PERMISSIONS.PERMISSIONS.CREATE],
},
{
path: '/permissions/update',
permissions: [PERMISSIONS.PERMISSIONS.UPDATE],
},
{
path: '/permissions/detail',
permissions: [PERMISSIONS.PERMISSIONS.READ_DETAIL],
},
];
//TODO : Fix this later
// const redirectToFirstAccessibleRoute = (userPermissions: string[]) => {
// const fallback = mappingRoutePermissions.find((route) =>
// route.permissions.some((perm) => userPermissions.includes(perm))
// );
// return redirect(fallback?.path ?? '/auth/login');
// };
export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const pathname = url.pathname;
const session = SessionUser.get();
const session_token = SessionToken.get();
const token = session_token?.token?.access_token;
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (mappingPublicRoutes.includes(pathname)) {
if (token) return redirect('/hackathon-dashboard');
return null;
}
if (!session) return redirect('/auth/login');
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
);
if (matchedRoute) {
const hasPermission =
!matchedRoute.permissions ||
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return '/hackathon-dashboard';
}
}
return null;
};
+923
View File
@@ -0,0 +1,923 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as PublicRouteImport } from './routes/_public'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthenticatedUsersDimentorinRouteImport } from './routes/_authenticated/users-dimentorin'
import { Route as AuthenticatedTransactionsRouteImport } from './routes/_authenticated/transactions'
import { Route as AuthenticatedSettingsDimentorinRouteImport } from './routes/_authenticated/settings-dimentorin'
import { Route as AuthenticatedSessionDimentorinRouteImport } from './routes/_authenticated/session-dimentorin'
import { Route as AuthenticatedRolesRouteImport } from './routes/_authenticated/roles'
import { Route as AuthenticatedRoadmapDimentorinRouteImport } from './routes/_authenticated/roadmap-dimentorin'
import { Route as AuthenticatedPrizesRouteImport } from './routes/_authenticated/prizes'
import { Route as AuthenticatedPermissionsRouteImport } from './routes/_authenticated/permissions'
import { Route as AuthenticatedHackathonUsersRouteImport } from './routes/_authenticated/hackathon-users'
import { Route as AuthenticatedHackathonTeamsRouteImport } from './routes/_authenticated/hackathon-teams'
import { Route as AuthenticatedHackathonSubmissionsRouteImport } from './routes/_authenticated/hackathon-submissions'
import { Route as AuthenticatedHackathonDashboardRouteImport } from './routes/_authenticated/hackathon-dashboard'
import { Route as AuthenticatedGachaRollRouteImport } from './routes/_authenticated/gacha-roll'
import { Route as AuthenticatedFeedbackReviewDimentorinRouteImport } from './routes/_authenticated/feedback-review-dimentorin'
import { Route as AuthenticatedDashboardDimentorinRouteImport } from './routes/_authenticated/dashboard-dimentorin'
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
import { Route as AuthenticatedCmsTestimonialsRouteImport } from './routes/_authenticated/cms-testimonials'
import { Route as AuthenticatedCmsEventsRouteImport } from './routes/_authenticated/cms-events'
import { Route as AuthenticatedAccountsRouteImport } from './routes/_authenticated/accounts'
import { Route as PublicAuthLoginRouteImport } from './routes/_public/auth/login'
import { Route as AuthenticatedUsersDimentorinIdRouteImport } from './routes/_authenticated/users-dimentorin_/$id'
import { Route as AuthenticatedSessionDimentorinIdRouteImport } from './routes/_authenticated/session-dimentorin_/$id'
import { Route as AuthenticatedRolesCreateRouteImport } from './routes/_authenticated/roles_/create'
import { Route as AuthenticatedRolesIdRouteImport } from './routes/_authenticated/roles_/$id'
import { Route as AuthenticatedRoadmapDimentorinCreateRouteImport } from './routes/_authenticated/roadmap-dimentorin_/create'
import { Route as AuthenticatedRoadmapDimentorinIdRouteImport } from './routes/_authenticated/roadmap-dimentorin_/$id'
import { Route as AuthenticatedPermissionsCreateRouteImport } from './routes/_authenticated/permissions_/create'
import { Route as AuthenticatedPermissionsIdRouteImport } from './routes/_authenticated/permissions_/$id'
import { Route as AuthenticatedGachaRollCreateRouteImport } from './routes/_authenticated/gacha-roll_/create'
import { Route as AuthenticatedGachaRollIdRouteImport } from './routes/_authenticated/gacha-roll_/$id'
import { Route as AuthenticatedDashboardCreateRouteImport } from './routes/_authenticated/dashboard_/create'
import { Route as AuthenticatedDashboardIdRouteImport } from './routes/_authenticated/dashboard_/$id'
import { Route as AuthenticatedCmsTestimonialsCreateRouteImport } from './routes/_authenticated/cms-testimonials_/create'
import { Route as AuthenticatedCmsTestimonialsIdRouteImport } from './routes/_authenticated/cms-testimonials_/$id'
import { Route as AuthenticatedCmsEventsCreateRouteImport } from './routes/_authenticated/cms-events_/create'
import { Route as AuthenticatedCmsEventsIdRouteImport } from './routes/_authenticated/cms-events_/$id'
import { Route as AuthenticatedAccountsIdRouteImport } from './routes/_authenticated/accounts_/$id'
const PublicRoute = PublicRouteImport.update({
id: '/_public',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedRoute = AuthenticatedRouteImport.update({
id: '/_authenticated',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedUsersDimentorinRoute =
AuthenticatedUsersDimentorinRouteImport.update({
id: '/users-dimentorin',
path: '/users-dimentorin',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedTransactionsRoute =
AuthenticatedTransactionsRouteImport.update({
id: '/transactions',
path: '/transactions',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedSettingsDimentorinRoute =
AuthenticatedSettingsDimentorinRouteImport.update({
id: '/settings-dimentorin',
path: '/settings-dimentorin',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedSessionDimentorinRoute =
AuthenticatedSessionDimentorinRouteImport.update({
id: '/session-dimentorin',
path: '/session-dimentorin',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRolesRoute = AuthenticatedRolesRouteImport.update({
id: '/roles',
path: '/roles',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRoadmapDimentorinRoute =
AuthenticatedRoadmapDimentorinRouteImport.update({
id: '/roadmap-dimentorin',
path: '/roadmap-dimentorin',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedPrizesRoute = AuthenticatedPrizesRouteImport.update({
id: '/prizes',
path: '/prizes',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedPermissionsRoute =
AuthenticatedPermissionsRouteImport.update({
id: '/permissions',
path: '/permissions',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedHackathonUsersRoute =
AuthenticatedHackathonUsersRouteImport.update({
id: '/hackathon-users',
path: '/hackathon-users',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedHackathonTeamsRoute =
AuthenticatedHackathonTeamsRouteImport.update({
id: '/hackathon-teams',
path: '/hackathon-teams',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedHackathonSubmissionsRoute =
AuthenticatedHackathonSubmissionsRouteImport.update({
id: '/hackathon-submissions',
path: '/hackathon-submissions',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedHackathonDashboardRoute =
AuthenticatedHackathonDashboardRouteImport.update({
id: '/hackathon-dashboard',
path: '/hackathon-dashboard',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedGachaRollRoute = AuthenticatedGachaRollRouteImport.update({
id: '/gacha-roll',
path: '/gacha-roll',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedFeedbackReviewDimentorinRoute =
AuthenticatedFeedbackReviewDimentorinRouteImport.update({
id: '/feedback-review-dimentorin',
path: '/feedback-review-dimentorin',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedDashboardDimentorinRoute =
AuthenticatedDashboardDimentorinRouteImport.update({
id: '/dashboard-dimentorin',
path: '/dashboard-dimentorin',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedCmsTestimonialsRoute =
AuthenticatedCmsTestimonialsRouteImport.update({
id: '/cms-testimonials',
path: '/cms-testimonials',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedCmsEventsRoute = AuthenticatedCmsEventsRouteImport.update({
id: '/cms-events',
path: '/cms-events',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsRoute = AuthenticatedAccountsRouteImport.update({
id: '/accounts',
path: '/accounts',
getParentRoute: () => AuthenticatedRoute,
} as any)
const PublicAuthLoginRoute = PublicAuthLoginRouteImport.update({
id: '/auth/login',
path: '/auth/login',
getParentRoute: () => PublicRoute,
} as any)
const AuthenticatedUsersDimentorinIdRoute =
AuthenticatedUsersDimentorinIdRouteImport.update({
id: '/users-dimentorin_/$id',
path: '/users-dimentorin/$id',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedSessionDimentorinIdRoute =
AuthenticatedSessionDimentorinIdRouteImport.update({
id: '/session-dimentorin_/$id',
path: '/session-dimentorin/$id',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRolesCreateRoute =
AuthenticatedRolesCreateRouteImport.update({
id: '/roles_/create',
path: '/roles/create',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRolesIdRoute = AuthenticatedRolesIdRouteImport.update({
id: '/roles_/$id',
path: '/roles/$id',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRoadmapDimentorinCreateRoute =
AuthenticatedRoadmapDimentorinCreateRouteImport.update({
id: '/roadmap-dimentorin_/create',
path: '/roadmap-dimentorin/create',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRoadmapDimentorinIdRoute =
AuthenticatedRoadmapDimentorinIdRouteImport.update({
id: '/roadmap-dimentorin_/$id',
path: '/roadmap-dimentorin/$id',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedPermissionsCreateRoute =
AuthenticatedPermissionsCreateRouteImport.update({
id: '/permissions_/create',
path: '/permissions/create',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedPermissionsIdRoute =
AuthenticatedPermissionsIdRouteImport.update({
id: '/permissions_/$id',
path: '/permissions/$id',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedGachaRollCreateRoute =
AuthenticatedGachaRollCreateRouteImport.update({
id: '/gacha-roll_/create',
path: '/gacha-roll/create',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedGachaRollIdRoute =
AuthenticatedGachaRollIdRouteImport.update({
id: '/gacha-roll_/$id',
path: '/gacha-roll/$id',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedDashboardCreateRoute =
AuthenticatedDashboardCreateRouteImport.update({
id: '/dashboard_/create',
path: '/dashboard/create',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedDashboardIdRoute =
AuthenticatedDashboardIdRouteImport.update({
id: '/dashboard_/$id',
path: '/dashboard/$id',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedCmsTestimonialsCreateRoute =
AuthenticatedCmsTestimonialsCreateRouteImport.update({
id: '/cms-testimonials_/create',
path: '/cms-testimonials/create',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedCmsTestimonialsIdRoute =
AuthenticatedCmsTestimonialsIdRouteImport.update({
id: '/cms-testimonials_/$id',
path: '/cms-testimonials/$id',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedCmsEventsCreateRoute =
AuthenticatedCmsEventsCreateRouteImport.update({
id: '/cms-events_/create',
path: '/cms-events/create',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedCmsEventsIdRoute =
AuthenticatedCmsEventsIdRouteImport.update({
id: '/cms-events_/$id',
path: '/cms-events/$id',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsIdRoute = AuthenticatedAccountsIdRouteImport.update({
id: '/accounts_/$id',
path: '/accounts/$id',
getParentRoute: () => AuthenticatedRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/accounts': typeof AuthenticatedAccountsRoute
'/cms-events': typeof AuthenticatedCmsEventsRoute
'/cms-testimonials': typeof AuthenticatedCmsTestimonialsRoute
'/dashboard': typeof AuthenticatedDashboardRoute
'/dashboard-dimentorin': typeof AuthenticatedDashboardDimentorinRoute
'/feedback-review-dimentorin': typeof AuthenticatedFeedbackReviewDimentorinRoute
'/gacha-roll': typeof AuthenticatedGachaRollRoute
'/hackathon-dashboard': typeof AuthenticatedHackathonDashboardRoute
'/hackathon-submissions': typeof AuthenticatedHackathonSubmissionsRoute
'/hackathon-teams': typeof AuthenticatedHackathonTeamsRoute
'/hackathon-users': typeof AuthenticatedHackathonUsersRoute
'/permissions': typeof AuthenticatedPermissionsRoute
'/prizes': typeof AuthenticatedPrizesRoute
'/roadmap-dimentorin': typeof AuthenticatedRoadmapDimentorinRoute
'/roles': typeof AuthenticatedRolesRoute
'/session-dimentorin': typeof AuthenticatedSessionDimentorinRoute
'/settings-dimentorin': typeof AuthenticatedSettingsDimentorinRoute
'/transactions': typeof AuthenticatedTransactionsRoute
'/users-dimentorin': typeof AuthenticatedUsersDimentorinRoute
'/accounts/$id': typeof AuthenticatedAccountsIdRoute
'/cms-events/$id': typeof AuthenticatedCmsEventsIdRoute
'/cms-events/create': typeof AuthenticatedCmsEventsCreateRoute
'/cms-testimonials/$id': typeof AuthenticatedCmsTestimonialsIdRoute
'/cms-testimonials/create': typeof AuthenticatedCmsTestimonialsCreateRoute
'/dashboard/$id': typeof AuthenticatedDashboardIdRoute
'/dashboard/create': typeof AuthenticatedDashboardCreateRoute
'/gacha-roll/$id': typeof AuthenticatedGachaRollIdRoute
'/gacha-roll/create': typeof AuthenticatedGachaRollCreateRoute
'/permissions/$id': typeof AuthenticatedPermissionsIdRoute
'/permissions/create': typeof AuthenticatedPermissionsCreateRoute
'/roadmap-dimentorin/$id': typeof AuthenticatedRoadmapDimentorinIdRoute
'/roadmap-dimentorin/create': typeof AuthenticatedRoadmapDimentorinCreateRoute
'/roles/$id': typeof AuthenticatedRolesIdRoute
'/roles/create': typeof AuthenticatedRolesCreateRoute
'/session-dimentorin/$id': typeof AuthenticatedSessionDimentorinIdRoute
'/users-dimentorin/$id': typeof AuthenticatedUsersDimentorinIdRoute
'/auth/login': typeof PublicAuthLoginRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/accounts': typeof AuthenticatedAccountsRoute
'/cms-events': typeof AuthenticatedCmsEventsRoute
'/cms-testimonials': typeof AuthenticatedCmsTestimonialsRoute
'/dashboard': typeof AuthenticatedDashboardRoute
'/dashboard-dimentorin': typeof AuthenticatedDashboardDimentorinRoute
'/feedback-review-dimentorin': typeof AuthenticatedFeedbackReviewDimentorinRoute
'/gacha-roll': typeof AuthenticatedGachaRollRoute
'/hackathon-dashboard': typeof AuthenticatedHackathonDashboardRoute
'/hackathon-submissions': typeof AuthenticatedHackathonSubmissionsRoute
'/hackathon-teams': typeof AuthenticatedHackathonTeamsRoute
'/hackathon-users': typeof AuthenticatedHackathonUsersRoute
'/permissions': typeof AuthenticatedPermissionsRoute
'/prizes': typeof AuthenticatedPrizesRoute
'/roadmap-dimentorin': typeof AuthenticatedRoadmapDimentorinRoute
'/roles': typeof AuthenticatedRolesRoute
'/session-dimentorin': typeof AuthenticatedSessionDimentorinRoute
'/settings-dimentorin': typeof AuthenticatedSettingsDimentorinRoute
'/transactions': typeof AuthenticatedTransactionsRoute
'/users-dimentorin': typeof AuthenticatedUsersDimentorinRoute
'/accounts/$id': typeof AuthenticatedAccountsIdRoute
'/cms-events/$id': typeof AuthenticatedCmsEventsIdRoute
'/cms-events/create': typeof AuthenticatedCmsEventsCreateRoute
'/cms-testimonials/$id': typeof AuthenticatedCmsTestimonialsIdRoute
'/cms-testimonials/create': typeof AuthenticatedCmsTestimonialsCreateRoute
'/dashboard/$id': typeof AuthenticatedDashboardIdRoute
'/dashboard/create': typeof AuthenticatedDashboardCreateRoute
'/gacha-roll/$id': typeof AuthenticatedGachaRollIdRoute
'/gacha-roll/create': typeof AuthenticatedGachaRollCreateRoute
'/permissions/$id': typeof AuthenticatedPermissionsIdRoute
'/permissions/create': typeof AuthenticatedPermissionsCreateRoute
'/roadmap-dimentorin/$id': typeof AuthenticatedRoadmapDimentorinIdRoute
'/roadmap-dimentorin/create': typeof AuthenticatedRoadmapDimentorinCreateRoute
'/roles/$id': typeof AuthenticatedRolesIdRoute
'/roles/create': typeof AuthenticatedRolesCreateRoute
'/session-dimentorin/$id': typeof AuthenticatedSessionDimentorinIdRoute
'/users-dimentorin/$id': typeof AuthenticatedUsersDimentorinIdRoute
'/auth/login': typeof PublicAuthLoginRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/_public': typeof PublicRouteWithChildren
'/_authenticated/accounts': typeof AuthenticatedAccountsRoute
'/_authenticated/cms-events': typeof AuthenticatedCmsEventsRoute
'/_authenticated/cms-testimonials': typeof AuthenticatedCmsTestimonialsRoute
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
'/_authenticated/dashboard-dimentorin': typeof AuthenticatedDashboardDimentorinRoute
'/_authenticated/feedback-review-dimentorin': typeof AuthenticatedFeedbackReviewDimentorinRoute
'/_authenticated/gacha-roll': typeof AuthenticatedGachaRollRoute
'/_authenticated/hackathon-dashboard': typeof AuthenticatedHackathonDashboardRoute
'/_authenticated/hackathon-submissions': typeof AuthenticatedHackathonSubmissionsRoute
'/_authenticated/hackathon-teams': typeof AuthenticatedHackathonTeamsRoute
'/_authenticated/hackathon-users': typeof AuthenticatedHackathonUsersRoute
'/_authenticated/permissions': typeof AuthenticatedPermissionsRoute
'/_authenticated/prizes': typeof AuthenticatedPrizesRoute
'/_authenticated/roadmap-dimentorin': typeof AuthenticatedRoadmapDimentorinRoute
'/_authenticated/roles': typeof AuthenticatedRolesRoute
'/_authenticated/session-dimentorin': typeof AuthenticatedSessionDimentorinRoute
'/_authenticated/settings-dimentorin': typeof AuthenticatedSettingsDimentorinRoute
'/_authenticated/transactions': typeof AuthenticatedTransactionsRoute
'/_authenticated/users-dimentorin': typeof AuthenticatedUsersDimentorinRoute
'/_authenticated/accounts_/$id': typeof AuthenticatedAccountsIdRoute
'/_authenticated/cms-events_/$id': typeof AuthenticatedCmsEventsIdRoute
'/_authenticated/cms-events_/create': typeof AuthenticatedCmsEventsCreateRoute
'/_authenticated/cms-testimonials_/$id': typeof AuthenticatedCmsTestimonialsIdRoute
'/_authenticated/cms-testimonials_/create': typeof AuthenticatedCmsTestimonialsCreateRoute
'/_authenticated/dashboard_/$id': typeof AuthenticatedDashboardIdRoute
'/_authenticated/dashboard_/create': typeof AuthenticatedDashboardCreateRoute
'/_authenticated/gacha-roll_/$id': typeof AuthenticatedGachaRollIdRoute
'/_authenticated/gacha-roll_/create': typeof AuthenticatedGachaRollCreateRoute
'/_authenticated/permissions_/$id': typeof AuthenticatedPermissionsIdRoute
'/_authenticated/permissions_/create': typeof AuthenticatedPermissionsCreateRoute
'/_authenticated/roadmap-dimentorin_/$id': typeof AuthenticatedRoadmapDimentorinIdRoute
'/_authenticated/roadmap-dimentorin_/create': typeof AuthenticatedRoadmapDimentorinCreateRoute
'/_authenticated/roles_/$id': typeof AuthenticatedRolesIdRoute
'/_authenticated/roles_/create': typeof AuthenticatedRolesCreateRoute
'/_authenticated/session-dimentorin_/$id': typeof AuthenticatedSessionDimentorinIdRoute
'/_authenticated/users-dimentorin_/$id': typeof AuthenticatedUsersDimentorinIdRoute
'/_public/auth/login': typeof PublicAuthLoginRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/accounts'
| '/cms-events'
| '/cms-testimonials'
| '/dashboard'
| '/dashboard-dimentorin'
| '/feedback-review-dimentorin'
| '/gacha-roll'
| '/hackathon-dashboard'
| '/hackathon-submissions'
| '/hackathon-teams'
| '/hackathon-users'
| '/permissions'
| '/prizes'
| '/roadmap-dimentorin'
| '/roles'
| '/session-dimentorin'
| '/settings-dimentorin'
| '/transactions'
| '/users-dimentorin'
| '/accounts/$id'
| '/cms-events/$id'
| '/cms-events/create'
| '/cms-testimonials/$id'
| '/cms-testimonials/create'
| '/dashboard/$id'
| '/dashboard/create'
| '/gacha-roll/$id'
| '/gacha-roll/create'
| '/permissions/$id'
| '/permissions/create'
| '/roadmap-dimentorin/$id'
| '/roadmap-dimentorin/create'
| '/roles/$id'
| '/roles/create'
| '/session-dimentorin/$id'
| '/users-dimentorin/$id'
| '/auth/login'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/accounts'
| '/cms-events'
| '/cms-testimonials'
| '/dashboard'
| '/dashboard-dimentorin'
| '/feedback-review-dimentorin'
| '/gacha-roll'
| '/hackathon-dashboard'
| '/hackathon-submissions'
| '/hackathon-teams'
| '/hackathon-users'
| '/permissions'
| '/prizes'
| '/roadmap-dimentorin'
| '/roles'
| '/session-dimentorin'
| '/settings-dimentorin'
| '/transactions'
| '/users-dimentorin'
| '/accounts/$id'
| '/cms-events/$id'
| '/cms-events/create'
| '/cms-testimonials/$id'
| '/cms-testimonials/create'
| '/dashboard/$id'
| '/dashboard/create'
| '/gacha-roll/$id'
| '/gacha-roll/create'
| '/permissions/$id'
| '/permissions/create'
| '/roadmap-dimentorin/$id'
| '/roadmap-dimentorin/create'
| '/roles/$id'
| '/roles/create'
| '/session-dimentorin/$id'
| '/users-dimentorin/$id'
| '/auth/login'
id:
| '__root__'
| '/'
| '/_authenticated'
| '/_public'
| '/_authenticated/accounts'
| '/_authenticated/cms-events'
| '/_authenticated/cms-testimonials'
| '/_authenticated/dashboard'
| '/_authenticated/dashboard-dimentorin'
| '/_authenticated/feedback-review-dimentorin'
| '/_authenticated/gacha-roll'
| '/_authenticated/hackathon-dashboard'
| '/_authenticated/hackathon-submissions'
| '/_authenticated/hackathon-teams'
| '/_authenticated/hackathon-users'
| '/_authenticated/permissions'
| '/_authenticated/prizes'
| '/_authenticated/roadmap-dimentorin'
| '/_authenticated/roles'
| '/_authenticated/session-dimentorin'
| '/_authenticated/settings-dimentorin'
| '/_authenticated/transactions'
| '/_authenticated/users-dimentorin'
| '/_authenticated/accounts_/$id'
| '/_authenticated/cms-events_/$id'
| '/_authenticated/cms-events_/create'
| '/_authenticated/cms-testimonials_/$id'
| '/_authenticated/cms-testimonials_/create'
| '/_authenticated/dashboard_/$id'
| '/_authenticated/dashboard_/create'
| '/_authenticated/gacha-roll_/$id'
| '/_authenticated/gacha-roll_/create'
| '/_authenticated/permissions_/$id'
| '/_authenticated/permissions_/create'
| '/_authenticated/roadmap-dimentorin_/$id'
| '/_authenticated/roadmap-dimentorin_/create'
| '/_authenticated/roles_/$id'
| '/_authenticated/roles_/create'
| '/_authenticated/session-dimentorin_/$id'
| '/_authenticated/users-dimentorin_/$id'
| '/_public/auth/login'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
PublicRoute: typeof PublicRouteWithChildren
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_public': {
id: '/_public'
path: ''
fullPath: '/'
preLoaderRoute: typeof PublicRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated': {
id: '/_authenticated'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthenticatedRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated/users-dimentorin': {
id: '/_authenticated/users-dimentorin'
path: '/users-dimentorin'
fullPath: '/users-dimentorin'
preLoaderRoute: typeof AuthenticatedUsersDimentorinRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/transactions': {
id: '/_authenticated/transactions'
path: '/transactions'
fullPath: '/transactions'
preLoaderRoute: typeof AuthenticatedTransactionsRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/settings-dimentorin': {
id: '/_authenticated/settings-dimentorin'
path: '/settings-dimentorin'
fullPath: '/settings-dimentorin'
preLoaderRoute: typeof AuthenticatedSettingsDimentorinRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/session-dimentorin': {
id: '/_authenticated/session-dimentorin'
path: '/session-dimentorin'
fullPath: '/session-dimentorin'
preLoaderRoute: typeof AuthenticatedSessionDimentorinRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/roles': {
id: '/_authenticated/roles'
path: '/roles'
fullPath: '/roles'
preLoaderRoute: typeof AuthenticatedRolesRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/roadmap-dimentorin': {
id: '/_authenticated/roadmap-dimentorin'
path: '/roadmap-dimentorin'
fullPath: '/roadmap-dimentorin'
preLoaderRoute: typeof AuthenticatedRoadmapDimentorinRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/prizes': {
id: '/_authenticated/prizes'
path: '/prizes'
fullPath: '/prizes'
preLoaderRoute: typeof AuthenticatedPrizesRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/permissions': {
id: '/_authenticated/permissions'
path: '/permissions'
fullPath: '/permissions'
preLoaderRoute: typeof AuthenticatedPermissionsRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/hackathon-users': {
id: '/_authenticated/hackathon-users'
path: '/hackathon-users'
fullPath: '/hackathon-users'
preLoaderRoute: typeof AuthenticatedHackathonUsersRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/hackathon-teams': {
id: '/_authenticated/hackathon-teams'
path: '/hackathon-teams'
fullPath: '/hackathon-teams'
preLoaderRoute: typeof AuthenticatedHackathonTeamsRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/hackathon-submissions': {
id: '/_authenticated/hackathon-submissions'
path: '/hackathon-submissions'
fullPath: '/hackathon-submissions'
preLoaderRoute: typeof AuthenticatedHackathonSubmissionsRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/hackathon-dashboard': {
id: '/_authenticated/hackathon-dashboard'
path: '/hackathon-dashboard'
fullPath: '/hackathon-dashboard'
preLoaderRoute: typeof AuthenticatedHackathonDashboardRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/gacha-roll': {
id: '/_authenticated/gacha-roll'
path: '/gacha-roll'
fullPath: '/gacha-roll'
preLoaderRoute: typeof AuthenticatedGachaRollRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/feedback-review-dimentorin': {
id: '/_authenticated/feedback-review-dimentorin'
path: '/feedback-review-dimentorin'
fullPath: '/feedback-review-dimentorin'
preLoaderRoute: typeof AuthenticatedFeedbackReviewDimentorinRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/dashboard-dimentorin': {
id: '/_authenticated/dashboard-dimentorin'
path: '/dashboard-dimentorin'
fullPath: '/dashboard-dimentorin'
preLoaderRoute: typeof AuthenticatedDashboardDimentorinRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/dashboard': {
id: '/_authenticated/dashboard'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof AuthenticatedDashboardRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/cms-testimonials': {
id: '/_authenticated/cms-testimonials'
path: '/cms-testimonials'
fullPath: '/cms-testimonials'
preLoaderRoute: typeof AuthenticatedCmsTestimonialsRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/cms-events': {
id: '/_authenticated/cms-events'
path: '/cms-events'
fullPath: '/cms-events'
preLoaderRoute: typeof AuthenticatedCmsEventsRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts': {
id: '/_authenticated/accounts'
path: '/accounts'
fullPath: '/accounts'
preLoaderRoute: typeof AuthenticatedAccountsRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_public/auth/login': {
id: '/_public/auth/login'
path: '/auth/login'
fullPath: '/auth/login'
preLoaderRoute: typeof PublicAuthLoginRouteImport
parentRoute: typeof PublicRoute
}
'/_authenticated/users-dimentorin_/$id': {
id: '/_authenticated/users-dimentorin_/$id'
path: '/users-dimentorin/$id'
fullPath: '/users-dimentorin/$id'
preLoaderRoute: typeof AuthenticatedUsersDimentorinIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/session-dimentorin_/$id': {
id: '/_authenticated/session-dimentorin_/$id'
path: '/session-dimentorin/$id'
fullPath: '/session-dimentorin/$id'
preLoaderRoute: typeof AuthenticatedSessionDimentorinIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/roles_/create': {
id: '/_authenticated/roles_/create'
path: '/roles/create'
fullPath: '/roles/create'
preLoaderRoute: typeof AuthenticatedRolesCreateRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/roles_/$id': {
id: '/_authenticated/roles_/$id'
path: '/roles/$id'
fullPath: '/roles/$id'
preLoaderRoute: typeof AuthenticatedRolesIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/roadmap-dimentorin_/create': {
id: '/_authenticated/roadmap-dimentorin_/create'
path: '/roadmap-dimentorin/create'
fullPath: '/roadmap-dimentorin/create'
preLoaderRoute: typeof AuthenticatedRoadmapDimentorinCreateRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/roadmap-dimentorin_/$id': {
id: '/_authenticated/roadmap-dimentorin_/$id'
path: '/roadmap-dimentorin/$id'
fullPath: '/roadmap-dimentorin/$id'
preLoaderRoute: typeof AuthenticatedRoadmapDimentorinIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/permissions_/create': {
id: '/_authenticated/permissions_/create'
path: '/permissions/create'
fullPath: '/permissions/create'
preLoaderRoute: typeof AuthenticatedPermissionsCreateRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/permissions_/$id': {
id: '/_authenticated/permissions_/$id'
path: '/permissions/$id'
fullPath: '/permissions/$id'
preLoaderRoute: typeof AuthenticatedPermissionsIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/gacha-roll_/create': {
id: '/_authenticated/gacha-roll_/create'
path: '/gacha-roll/create'
fullPath: '/gacha-roll/create'
preLoaderRoute: typeof AuthenticatedGachaRollCreateRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/gacha-roll_/$id': {
id: '/_authenticated/gacha-roll_/$id'
path: '/gacha-roll/$id'
fullPath: '/gacha-roll/$id'
preLoaderRoute: typeof AuthenticatedGachaRollIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/dashboard_/create': {
id: '/_authenticated/dashboard_/create'
path: '/dashboard/create'
fullPath: '/dashboard/create'
preLoaderRoute: typeof AuthenticatedDashboardCreateRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/dashboard_/$id': {
id: '/_authenticated/dashboard_/$id'
path: '/dashboard/$id'
fullPath: '/dashboard/$id'
preLoaderRoute: typeof AuthenticatedDashboardIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/cms-testimonials_/create': {
id: '/_authenticated/cms-testimonials_/create'
path: '/cms-testimonials/create'
fullPath: '/cms-testimonials/create'
preLoaderRoute: typeof AuthenticatedCmsTestimonialsCreateRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/cms-testimonials_/$id': {
id: '/_authenticated/cms-testimonials_/$id'
path: '/cms-testimonials/$id'
fullPath: '/cms-testimonials/$id'
preLoaderRoute: typeof AuthenticatedCmsTestimonialsIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/cms-events_/create': {
id: '/_authenticated/cms-events_/create'
path: '/cms-events/create'
fullPath: '/cms-events/create'
preLoaderRoute: typeof AuthenticatedCmsEventsCreateRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/cms-events_/$id': {
id: '/_authenticated/cms-events_/$id'
path: '/cms-events/$id'
fullPath: '/cms-events/$id'
preLoaderRoute: typeof AuthenticatedCmsEventsIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts_/$id': {
id: '/_authenticated/accounts_/$id'
path: '/accounts/$id'
fullPath: '/accounts/$id'
preLoaderRoute: typeof AuthenticatedAccountsIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
}
}
interface AuthenticatedRouteChildren {
AuthenticatedAccountsRoute: typeof AuthenticatedAccountsRoute
AuthenticatedCmsEventsRoute: typeof AuthenticatedCmsEventsRoute
AuthenticatedCmsTestimonialsRoute: typeof AuthenticatedCmsTestimonialsRoute
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
AuthenticatedDashboardDimentorinRoute: typeof AuthenticatedDashboardDimentorinRoute
AuthenticatedFeedbackReviewDimentorinRoute: typeof AuthenticatedFeedbackReviewDimentorinRoute
AuthenticatedGachaRollRoute: typeof AuthenticatedGachaRollRoute
AuthenticatedHackathonDashboardRoute: typeof AuthenticatedHackathonDashboardRoute
AuthenticatedHackathonSubmissionsRoute: typeof AuthenticatedHackathonSubmissionsRoute
AuthenticatedHackathonTeamsRoute: typeof AuthenticatedHackathonTeamsRoute
AuthenticatedHackathonUsersRoute: typeof AuthenticatedHackathonUsersRoute
AuthenticatedPermissionsRoute: typeof AuthenticatedPermissionsRoute
AuthenticatedPrizesRoute: typeof AuthenticatedPrizesRoute
AuthenticatedRoadmapDimentorinRoute: typeof AuthenticatedRoadmapDimentorinRoute
AuthenticatedRolesRoute: typeof AuthenticatedRolesRoute
AuthenticatedSessionDimentorinRoute: typeof AuthenticatedSessionDimentorinRoute
AuthenticatedSettingsDimentorinRoute: typeof AuthenticatedSettingsDimentorinRoute
AuthenticatedTransactionsRoute: typeof AuthenticatedTransactionsRoute
AuthenticatedUsersDimentorinRoute: typeof AuthenticatedUsersDimentorinRoute
AuthenticatedAccountsIdRoute: typeof AuthenticatedAccountsIdRoute
AuthenticatedCmsEventsIdRoute: typeof AuthenticatedCmsEventsIdRoute
AuthenticatedCmsEventsCreateRoute: typeof AuthenticatedCmsEventsCreateRoute
AuthenticatedCmsTestimonialsIdRoute: typeof AuthenticatedCmsTestimonialsIdRoute
AuthenticatedCmsTestimonialsCreateRoute: typeof AuthenticatedCmsTestimonialsCreateRoute
AuthenticatedDashboardIdRoute: typeof AuthenticatedDashboardIdRoute
AuthenticatedDashboardCreateRoute: typeof AuthenticatedDashboardCreateRoute
AuthenticatedGachaRollIdRoute: typeof AuthenticatedGachaRollIdRoute
AuthenticatedGachaRollCreateRoute: typeof AuthenticatedGachaRollCreateRoute
AuthenticatedPermissionsIdRoute: typeof AuthenticatedPermissionsIdRoute
AuthenticatedPermissionsCreateRoute: typeof AuthenticatedPermissionsCreateRoute
AuthenticatedRoadmapDimentorinIdRoute: typeof AuthenticatedRoadmapDimentorinIdRoute
AuthenticatedRoadmapDimentorinCreateRoute: typeof AuthenticatedRoadmapDimentorinCreateRoute
AuthenticatedRolesIdRoute: typeof AuthenticatedRolesIdRoute
AuthenticatedRolesCreateRoute: typeof AuthenticatedRolesCreateRoute
AuthenticatedSessionDimentorinIdRoute: typeof AuthenticatedSessionDimentorinIdRoute
AuthenticatedUsersDimentorinIdRoute: typeof AuthenticatedUsersDimentorinIdRoute
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedAccountsRoute: AuthenticatedAccountsRoute,
AuthenticatedCmsEventsRoute: AuthenticatedCmsEventsRoute,
AuthenticatedCmsTestimonialsRoute: AuthenticatedCmsTestimonialsRoute,
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
AuthenticatedDashboardDimentorinRoute: AuthenticatedDashboardDimentorinRoute,
AuthenticatedFeedbackReviewDimentorinRoute:
AuthenticatedFeedbackReviewDimentorinRoute,
AuthenticatedGachaRollRoute: AuthenticatedGachaRollRoute,
AuthenticatedHackathonDashboardRoute: AuthenticatedHackathonDashboardRoute,
AuthenticatedHackathonSubmissionsRoute:
AuthenticatedHackathonSubmissionsRoute,
AuthenticatedHackathonTeamsRoute: AuthenticatedHackathonTeamsRoute,
AuthenticatedHackathonUsersRoute: AuthenticatedHackathonUsersRoute,
AuthenticatedPermissionsRoute: AuthenticatedPermissionsRoute,
AuthenticatedPrizesRoute: AuthenticatedPrizesRoute,
AuthenticatedRoadmapDimentorinRoute: AuthenticatedRoadmapDimentorinRoute,
AuthenticatedRolesRoute: AuthenticatedRolesRoute,
AuthenticatedSessionDimentorinRoute: AuthenticatedSessionDimentorinRoute,
AuthenticatedSettingsDimentorinRoute: AuthenticatedSettingsDimentorinRoute,
AuthenticatedTransactionsRoute: AuthenticatedTransactionsRoute,
AuthenticatedUsersDimentorinRoute: AuthenticatedUsersDimentorinRoute,
AuthenticatedAccountsIdRoute: AuthenticatedAccountsIdRoute,
AuthenticatedCmsEventsIdRoute: AuthenticatedCmsEventsIdRoute,
AuthenticatedCmsEventsCreateRoute: AuthenticatedCmsEventsCreateRoute,
AuthenticatedCmsTestimonialsIdRoute: AuthenticatedCmsTestimonialsIdRoute,
AuthenticatedCmsTestimonialsCreateRoute:
AuthenticatedCmsTestimonialsCreateRoute,
AuthenticatedDashboardIdRoute: AuthenticatedDashboardIdRoute,
AuthenticatedDashboardCreateRoute: AuthenticatedDashboardCreateRoute,
AuthenticatedGachaRollIdRoute: AuthenticatedGachaRollIdRoute,
AuthenticatedGachaRollCreateRoute: AuthenticatedGachaRollCreateRoute,
AuthenticatedPermissionsIdRoute: AuthenticatedPermissionsIdRoute,
AuthenticatedPermissionsCreateRoute: AuthenticatedPermissionsCreateRoute,
AuthenticatedRoadmapDimentorinIdRoute: AuthenticatedRoadmapDimentorinIdRoute,
AuthenticatedRoadmapDimentorinCreateRoute:
AuthenticatedRoadmapDimentorinCreateRoute,
AuthenticatedRolesIdRoute: AuthenticatedRolesIdRoute,
AuthenticatedRolesCreateRoute: AuthenticatedRolesCreateRoute,
AuthenticatedSessionDimentorinIdRoute: AuthenticatedSessionDimentorinIdRoute,
AuthenticatedUsersDimentorinIdRoute: AuthenticatedUsersDimentorinIdRoute,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
AuthenticatedRouteChildren,
)
interface PublicRouteChildren {
PublicAuthLoginRoute: typeof PublicAuthLoginRoute
}
const PublicRouteChildren: PublicRouteChildren = {
PublicAuthLoginRoute: PublicAuthLoginRoute,
}
const PublicRouteWithChildren =
PublicRoute._addFileChildren(PublicRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthenticatedRoute: AuthenticatedRouteWithChildren,
PublicRoute: PublicRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
+5
View File
@@ -0,0 +1,5 @@
import { createRootRoute, Outlet } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => <Outlet />,
})
@@ -0,0 +1,28 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
import { SessionToken } from '@imphnen-frontend-service/service';
import {
SidebarInset,
SidebarProvider,
} from '@imphnen-frontend-service/ui/atoms';
import { BackofficeSidebar } from '../components/sidebar';
export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => {
const session = SessionToken.get();
if (!session?.token?.access_token) {
throw redirect({ to: '/auth/login' });
}
},
component: AuthenticatedLayout,
});
function AuthenticatedLayout() {
return (
<SidebarProvider>
<BackofficeSidebar />
<SidebarInset>
<Outlet />
</SidebarInset>
</SidebarProvider>
);
}
@@ -1,5 +1,4 @@
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts" import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
import { PieLabelProps } from "recharts/types/polar/Pie"
type ChartProps = { type ChartProps = {
name: string name: string
@@ -14,14 +13,20 @@ const chartData: ChartProps[] = [
] ]
const RADIAN = Math.PI / 180; const RADIAN = Math.PI / 180;
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: PieLabelProps) => { const renderCustomizedLabel = (props: any) => {
const cx = props.cx ?? 0
const cy = props.cy ?? 0
const midAngle = props.midAngle ?? 0
const innerRadius = props.innerRadius ?? 0
const outerRadius = props.outerRadius ?? 0
const percent = props.percent ?? 0
const radius = innerRadius + (outerRadius - innerRadius) * 0.5; const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN); const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN); const y = cy + radius * Math.sin(-midAngle * RADIAN);
return ( return (
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central"> <text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
{`${((percent ?? 1) * 100).toFixed(0)}%`} {`${(percent * 100).toFixed(0)}%`}
</text> </text>
); );
}; };
@@ -40,7 +40,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
return ( return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200"> <div className="flex items-center justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center"> <div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
@@ -68,9 +68,8 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
</button> </button>
</div> </div>
{/* Content */}
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Submission Status */}
<div <div
className={cn( className={cn(
'flex items-center gap-3 p-4 border rounded-lg', 'flex items-center gap-3 p-4 border rounded-lg',
@@ -96,7 +95,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
</div> </div>
</div> </div>
{/* Project Description */}
<div> <div>
<h3 className="text-sm font-medium text-neutral-700 mb-2"> <h3 className="text-sm font-medium text-neutral-700 mb-2">
Project Description Project Description
@@ -106,13 +104,11 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
</p> </p>
</div> </div>
{/* Project Links */}
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700"> <h3 className="text-sm font-medium text-neutral-700">
Project Links Project Links
</h3> </h3>
{/* Repository URL */}
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg"> <div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" /> <LinkOutlined className="text-primary-500 mt-1" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -130,7 +126,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
</div> </div>
</div> </div>
{/* Demo URL */}
{submission.demo_url && ( {submission.demo_url && (
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg"> <div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" /> <LinkOutlined className="text-primary-500 mt-1" />
@@ -150,7 +145,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
</div> </div>
)} )}
{/* Presentation URL */}
{submission.presentation_url && ( {submission.presentation_url && (
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg"> <div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" /> <LinkOutlined className="text-primary-500 mt-1" />
@@ -171,7 +165,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
)} )}
</div> </div>
{/* Screenshots */}
{submission.screenshots && submission.screenshots.length > 0 && ( {submission.screenshots && submission.screenshots.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2"> <h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
@@ -198,7 +191,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
</div> </div>
)} )}
{/* Metadata */}
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200"> <div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
<div> <div>
<p className="text-xs text-neutral-500 mb-1">Created</p> <p className="text-xs text-neutral-500 mb-1">Created</p>
@@ -227,19 +219,11 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
</div> </div>
</div> </div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50"> <div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
<Button variant="secondary" onClick={onClose}> <Button variant="secondary" onClick={onClose}>
Close Close
</Button> </Button>
{/* <Button
variant="primary"
onClick={() => {
console.log('Edit submission:', submission.id);
}}
>
Edit Status
</Button> */}
</div> </div>
</div> </div>
</div> </div>
@@ -33,7 +33,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
const logoInputRef = useRef<HTMLInputElement>(null); const logoInputRef = useRef<HTMLInputElement>(null);
const bannerInputRef = useRef<HTMLInputElement>(null); const bannerInputRef = useRef<HTMLInputElement>(null);
// Initialize form data when modal opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
if (team) { if (team) {
@@ -55,7 +54,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
} }
}, [isOpen, team]); }, [isOpen, team]);
// Check if form has changes
const hasChanges = useMemo(() => { const hasChanges = useMemo(() => {
if (!formData || !team) return !!formData; if (!formData || !team) return !!formData;
return ( return (
@@ -68,7 +66,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
); );
}, [formData, team]); }, [formData, team]);
// Check if required fields are filled
const isFormValid = useMemo(() => { const isFormValid = useMemo(() => {
if (!formData) return false; if (!formData) return false;
return ( return (
@@ -144,7 +141,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
return ( return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200"> <div className="flex items-center justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center"> <div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
@@ -172,7 +168,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
</button> </button>
</div> </div>
{/* Content */}
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}> <div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
<input <input
type="file" type="file"
@@ -189,7 +184,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
className="hidden" className="hidden"
/> />
{/* Banner Section */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">
Team Banner{' '} Team Banner{' '}
@@ -234,7 +228,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
</div> </div>
</div> </div>
{/* Logo & Name */}
<div className="grid grid-cols-12 gap-4 items-start"> <div className="grid grid-cols-12 gap-4 items-start">
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-neutral-700 mb-2"> <label className="block text-sm font-medium text-neutral-700 mb-2">
@@ -305,7 +298,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
</div> </div>
</div> </div>
{/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">
Description <span className="text-danger-500">*</span> Description <span className="text-danger-500">*</span>
@@ -319,7 +311,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
/> />
</div> </div>
{/* City */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">
City <span className="text-danger-500">*</span> City <span className="text-danger-500">*</span>
@@ -336,7 +327,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
/> />
</div> </div>
{/* Visibility */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">
Team Visibility Team Visibility
@@ -373,7 +363,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
</div> </div>
</div> </div>
{/* Team Details */}
{team && ( {team && (
<div className="space-y-4 border-t border-neutral-200 pt-4"> <div className="space-y-4 border-t border-neutral-200 pt-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -429,7 +418,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
)} )}
</div> </div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-neutral-200"> <div className="flex items-center justify-between p-6 border-t border-neutral-200">
<div> <div>
{team && ( {team && (
@@ -463,7 +451,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
</div> </div>
</div> </div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && ( {showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6"> <div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
@@ -15,7 +15,7 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
className = '', className = '',
showPlaceholder = true, showPlaceholder = true,
}) => { }) => {
const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio const aspectRatioClass = 'aspect-[3/1]';
if (!banner && !showPlaceholder) { if (!banner && !showPlaceholder) {
return null; return null;
@@ -35,7 +35,6 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
alt={`${teamName} banner`} alt={`${teamName} banner`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
onError={(e) => { onError={(e) => {
// Fallback to placeholder if image fails to load
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
target.style.display = 'none'; target.style.display = 'none';
const placeholder = target.nextElementSibling as HTMLElement; const placeholder = target.nextElementSibling as HTMLElement;
@@ -44,11 +43,10 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
} }
}} }}
/> />
{/* Fallback placeholder (hidden by default, shown on image error) */}
<div <div
className={cn( className={cn(
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center', 'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
'hidden' // Hidden by default 'hidden'
)} )}
> >
<div className="text-center"> <div className="text-center">
@@ -61,7 +59,6 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
); );
} }
// No banner - show placeholder
return ( return (
<div <div
className={cn( className={cn(
@@ -37,16 +37,13 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
const [showAvatarMenu, setShowAvatarMenu] = useState(false); const [showAvatarMenu, setShowAvatarMenu] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Initialize form data when modal opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
if (user) { if (user) {
// Edit existing user
setFormData({ ...user }); setFormData({ ...user });
} else { } else {
// Create new user
setFormData({ setFormData({
id: '', // Will be generated by backend id: '',
fullname: '', fullname: '',
bio: '', bio: '',
location: '', location: '',
@@ -60,10 +57,9 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
} }
}, [isOpen, user]); }, [isOpen, user]);
// Check if form has changes
const hasChanges = useMemo(() => { const hasChanges = useMemo(() => {
if (!formData) return false; if (!formData) return false;
if (!user) return true; // New user always has changes if (!user) return true;
return ( return (
formData.fullname !== user.fullname || formData.fullname !== user.fullname ||
formData.location !== user.location || formData.location !== user.location ||
@@ -74,7 +70,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
); );
}, [formData, user]); }, [formData, user]);
// Check if required fields are filled
const isFormValid = useMemo(() => { const isFormValid = useMemo(() => {
if (!formData) return false; if (!formData) return false;
return formData.fullname?.trim() !== '' && formData.location?.trim() !== ''; return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
@@ -99,25 +94,22 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
if (!formData) return; if (!formData) return;
if (user) { if (user) {
// Update existing user
console.log('Update user data:', formData); console.log('Update user data:', formData);
} else { } else {
// Create new user
console.log('Create new user:', formData); console.log('Create new user:', formData);
} }
// Here you would typically make an API call to save the data
onClose(); onClose();
}; };
const handleCancel = () => { const handleCancel = () => {
if (user) { if (user) {
setFormData({ ...user }); // Reset to original for edit mode setFormData({ ...user });
} }
onClose(); onClose();
}; };
const handleDeleteAccount = () => { const handleDeleteAccount = () => {
if (!user) return; // Can't delete new user if (!user) return;
console.log('Delete user:', user.id); console.log('Delete user:', user.id);
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
onClose(); onClose();
@@ -126,19 +118,16 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
// Validate file type
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
alert('Please select an image file'); alert('Please select an image file');
return; return;
} }
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB'); alert('Image size must be less than 5MB');
return; return;
} }
// Create preview URL
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
const avatarUrl = e.target?.result as string; const avatarUrl = e.target?.result as string;
@@ -183,7 +172,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto" className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Hidden File Input */}
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
@@ -191,10 +179,8 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
accept="image/*" accept="image/*"
className="hidden" className="hidden"
/> />
{/* Header */}
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start"> <div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Interactive User Avatar */}
<div className="relative group "> <div className="relative group ">
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors"> <div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
{formData.avatar ? ( {formData.avatar ? (
@@ -208,7 +194,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
)} )}
</div> </div>
{/* Avatar Hover Overlay */}
<button <button
onClick={() => setShowAvatarMenu(!showAvatarMenu)} onClick={() => setShowAvatarMenu(!showAvatarMenu)}
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center" className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
@@ -216,7 +201,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
<CameraOutlined className="text-white text-lg" /> <CameraOutlined className="text-white text-lg" />
</button> </button>
{/* Avatar Menu Dropdown */}
{showAvatarMenu && ( {showAvatarMenu && (
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10"> <div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
<button <button
@@ -269,17 +253,14 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</button> </button>
</div> </div>
{/* Content */}
<div className="p-8" onClick={() => setShowAvatarMenu(false)}> <div className="p-8" onClick={() => setShowAvatarMenu(false)}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column - Basic Info */}
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4"> <h3 className="text-lg font-semibold text-neutral-900 mb-4">
Basic Information Basic Information
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{/* Full Name - Required */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<UserOutlined className="text-neutral-400" /> <UserOutlined className="text-neutral-400" />
<div className="flex-1"> <div className="flex-1">
@@ -310,7 +291,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
{/* Location - Required */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<EnvironmentOutlined className="text-neutral-400" /> <EnvironmentOutlined className="text-neutral-400" />
<div className="flex-1"> <div className="flex-1">
@@ -346,7 +326,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
{/* Joined Date - Read Only - Only show for existing users */}
{user && ( {user && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<CalendarOutlined className="text-neutral-400" /> <CalendarOutlined className="text-neutral-400" />
@@ -370,7 +349,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
{/* Bio Section - Optional */}
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 mb-3"> <h3 className="text-lg font-semibold text-neutral-900 mb-3">
Bio{' '} Bio{' '}
@@ -390,9 +368,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
{/* Right Column - Skills & Status */}
<div className="space-y-6"> <div className="space-y-6">
{/* Account Status - Enhanced Tab Design */}
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4"> <h3 className="text-lg font-semibold text-neutral-900 mb-4">
Account Status Account Status
@@ -448,7 +424,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</p> </p>
</div> </div>
{/* Skills Section - Optional */}
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4"> <h3 className="text-lg font-semibold text-neutral-900 mb-4">
Skills & Expertise{' '} Skills & Expertise{' '}
@@ -510,7 +485,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
{/* Account Details - Read Only - Only show for existing users */}
{user && ( {user && (
<div> <div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4"> <h3 className="text-lg font-semibold text-neutral-900 mb-4">
@@ -547,7 +521,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
{/* Footer Actions */}
<div className="border-t border-neutral-200 px-8 py-6"> <div className="border-t border-neutral-200 px-8 py-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -558,7 +531,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
? 'Please fill required fields' ? 'Please fill required fields'
: 'No changes made'} : 'No changes made'}
</div> </div>
{/* Delete Account Button - Only show for existing users */}
{user && ( {user && (
<button <button
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
@@ -595,7 +567,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && ( {showDeleteConfirm && (
<div className="fixed inset-0 z-60"> <div className="fixed inset-0 z-60">
<div <div
@@ -1,4 +1,4 @@
import { Button, Input, Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms" import { Button, Input, NativeSelect as Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils" import { cn } from "@imphnen-frontend-service/utils"
import { FC } from "react" import { FC } from "react"
@@ -1,4 +1,4 @@
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms" import { Button, Input, NativeSelect as Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils" import { cn } from "@imphnen-frontend-service/utils"
import { FC } from "react" import { FC } from "react"
@@ -4,18 +4,8 @@ import { DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn } from "@imphnen-frontend-service/utils"; import { cn } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table"; import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { FC, useState } from "react"; import { FC, useState } from "react";
import { useRoleList, useDeleteRole, TRolesListItem } from "@imphnen-frontend-service/service";
type UserRolesPermissionType = { import { toast } from "sonner";
id: number
role: string
totalUser: number
}
const mockData: UserRolesPermissionType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
role: ['Admin', 'Super Admin', 'Mentee', 'Mentor'][Math.floor(Math.random() * 4)],
totalUser: 10
}))
export const UserRolesPermission: FC = () => { export const UserRolesPermission: FC = () => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
@@ -24,7 +14,25 @@ export const UserRolesPermission: FC = () => {
pageSize: 9, pageSize: 9,
}); });
const columns: ColumnDef<UserRolesPermissionType>[] = [ const { data: rolesData, isLoading } = useRoleList({
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
});
const deleteRole = useDeleteRole();
const roles: TRolesListItem[] = rolesData?.data ?? [];
const totalItems = rolesData?.meta?.total ?? roles.length;
const handleDelete = async (id: string) => {
try {
await deleteRole.mutateAsync(id);
toast.success('Role berhasil dihapus');
} catch {
toast.error('Role gagal dihapus');
}
};
const columns: ColumnDef<TRolesListItem>[] = [
{ {
id: 'select', id: 'select',
meta: { cellClassName: cn("w-20") }, meta: { cellClassName: cn("w-20") },
@@ -48,12 +56,12 @@ export const UserRolesPermission: FC = () => {
{ {
id: 'role', id: 'role',
header: 'Role', header: 'Role',
accessorKey: 'role', accessorKey: 'name',
}, },
{ {
id: 'totalUser', id: 'totalUser',
header: 'Total User', header: 'Total Permissions',
accessorKey: 'totalUser', accessorKey: 'permissions_count',
}, },
{ {
header: 'Action', header: 'Action',
@@ -75,6 +83,7 @@ export const UserRolesPermission: FC = () => {
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDelete(row.original.id);
}} }}
className="flex items-center gap-2 w-max" className="flex items-center gap-2 w-max"
> >
@@ -86,7 +95,7 @@ export const UserRolesPermission: FC = () => {
] ]
const table = useReactTable({ const table = useReactTable({
data: mockData, data: roles,
columns, columns,
state: { state: {
pagination, pagination,
@@ -97,8 +106,8 @@ export const UserRolesPermission: FC = () => {
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize), pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: false, manualPagination: true,
}); });
return ( return (
@@ -106,12 +115,16 @@ export const UserRolesPermission: FC = () => {
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex items-center justify-between">
<h1 className="text-p2 font-semibold text-neutral-700">User Roles & Permissions</h1> <h1 className="text-p2 font-semibold text-neutral-700">User Roles & Permissions</h1>
<Button type="button"> <Button type="button">
Add Rols Add Role
</Button> </Button>
</div> </div>
<div className="bg-white shadow p-8 rounded-lg"> <div className="bg-white shadow p-8 rounded-lg">
<DataTable data={mockData} columns={columns} table={table} /> {isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div>
) : (
<DataTable data={roles} columns={columns} table={table} />
)}
</div> </div>
</div> </div>
) )
@@ -1,23 +1,18 @@
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
// import { zodResolver } from '@hookform/resolvers/zod';
// import {
// gachaRollItemSchema,
// TGachaRollItem
// } from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
export const useItem = ( export const useItem = (
nextStep: () => void, nextStep: () => void,
initialValues?: any initialValues?: any,
onDataCapture?: (data: any) => void,
) => { ) => {
const form = useForm<any>({ const form = useForm<any>({
// resolver: zodResolver(),
mode: 'all', mode: 'all',
defaultValues: initialValues, defaultValues: initialValues,
}); });
const onSubmit = form.handleSubmit((data) => { const onSubmit = form.handleSubmit((data) => {
console.log('Form data:', data); onDataCapture?.(data);
nextStep(); nextStep();
}); });
@@ -38,8 +33,9 @@ export const useConfirmItem = (
) => { ) => {
const onConfirm = async () => { const onConfirm = async () => {
try { try {
// const result = await actionFunction?.(); if (actionFunction) {
// result ? toast.success(messages?.success) : toast.error(messages?.error); await actionFunction();
}
toast.success(messages?.success); toast.success(messages?.success);
onClose(); onClose();
resetStep(); resetStep();
@@ -1,23 +1,18 @@
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
// import { zodResolver } from '@hookform/resolvers/zod';
// import {
// gachaRollItemSchema,
// TGachaRollItem
// } from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
export const useItem = ( export const useItem = (
nextStep: () => void, nextStep: () => void,
initialValues?: any initialValues?: any,
onDataCapture?: (data: any) => void,
) => { ) => {
const form = useForm<any>({ const form = useForm<any>({
// resolver: zodResolver(),
mode: 'all', mode: 'all',
defaultValues: initialValues, defaultValues: initialValues,
}); });
const onSubmit = form.handleSubmit((data) => { const onSubmit = form.handleSubmit((data) => {
console.log('Form data:', data); onDataCapture?.(data);
nextStep(); nextStep();
}); });
@@ -38,8 +33,9 @@ export const useConfirmItem = (
) => { ) => {
const onConfirm = async () => { const onConfirm = async () => {
try { try {
// const result = await actionFunction?.(); if (actionFunction) {
// result ? toast.success(messages?.success) : toast.error(messages?.error); await actionFunction();
}
toast.success(messages?.success); toast.success(messages?.success);
onClose(); onClose();
resetStep(); resetStep();
@@ -8,16 +8,17 @@ import { toast } from 'sonner';
export const useItem = ( export const useItem = (
nextStep: () => void, nextStep: () => void,
initialValues?: TGachaItem initialValues?: Partial<TGachaItem>,
onDataCapture?: (data: any) => void,
) => { ) => {
const form = useForm<TGachaItem>({ const form = useForm<any>({
resolver: zodResolver(gachaItemSchema), resolver: zodResolver(gachaItemSchema),
mode: 'all', mode: 'all',
defaultValues: initialValues, defaultValues: initialValues,
}); });
const onSubmit = form.handleSubmit((data) => { const onSubmit = form.handleSubmit((data) => {
console.log('Form data:', data); onDataCapture?.(data);
nextStep(); nextStep();
}); });
@@ -38,8 +39,9 @@ export const useConfirmItem = (
) => { ) => {
const onConfirm = async () => { const onConfirm = async () => {
try { try {
// const result = await actionFunction?.(); if (actionFunction) {
// result ? toast.success(messages?.success) : toast.error(messages?.error); await actionFunction();
}
toast.success(messages?.success); toast.success(messages?.success);
onClose(); onClose();
resetStep(); resetStep();
@@ -8,16 +8,17 @@ import { toast } from 'sonner';
export const useItem = ( export const useItem = (
nextStep: () => void, nextStep: () => void,
initialValues?: TGachaRollItem initialValues?: Partial<TGachaRollItem>,
onDataCapture?: (data: any) => void,
) => { ) => {
const form = useForm<TGachaRollItem>({ const form = useForm<any>({
resolver: zodResolver(gachaRollItemSchema), resolver: zodResolver(gachaRollItemSchema),
mode: 'all', mode: 'all',
defaultValues: initialValues, defaultValues: initialValues,
}); });
const onSubmit = form.handleSubmit((data) => { const onSubmit = form.handleSubmit((data) => {
console.log('Form data:', data); onDataCapture?.(data);
nextStep(); nextStep();
}); });
@@ -38,8 +39,9 @@ export const useConfirmItem = (
) => { ) => {
const onConfirm = async () => { const onConfirm = async () => {
try { try {
// const result = await actionFunction?.(); if (actionFunction) {
// result ? toast.success(messages?.success) : toast.error(messages?.error); await actionFunction();
}
toast.success(messages?.success); toast.success(messages?.success);
onClose(); onClose();
resetStep(); resetStep();
@@ -0,0 +1,57 @@
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
export const useItem = (
nextStep: () => void,
initialValues?: any,
onDataCapture?: (data: any) => void,
) => {
const form = useForm<any>({
mode: 'all',
defaultValues: initialValues,
});
const onSubmit = form.handleSubmit((data) => {
onDataCapture?.(data);
nextStep();
});
return {
form,
onSubmit,
};
};
export const useConfirmItem = (
onClose: () => void,
resetStep: () => void,
actionFunction?: () => Promise<boolean>,
messages?: {
success?: string;
error?: string;
}
) => {
const onConfirm = async () => {
try {
if (actionFunction) {
await actionFunction();
}
toast.success(messages?.success);
onClose();
resetStep();
} catch (error) {
console.log(error);
toast.error(messages?.error);
}
};
const onCancel = () => {
onClose();
resetStep();
};
return {
onConfirm,
onCancel,
};
};
@@ -0,0 +1,57 @@
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
export const useItem = (
nextStep: () => void,
initialValues?: any,
onDataCapture?: (data: any) => void,
) => {
const form = useForm<any>({
mode: 'all',
defaultValues: initialValues,
});
const onSubmit = form.handleSubmit((data) => {
onDataCapture?.(data);
nextStep();
});
return {
form,
onSubmit,
};
};
export const useConfirmItem = (
onClose: () => void,
resetStep: () => void,
actionFunction?: () => Promise<boolean>,
messages?: {
success?: string;
error?: string;
}
) => {
const onConfirm = async () => {
try {
if (actionFunction) {
await actionFunction();
}
toast.success(messages?.success);
onClose();
resetStep();
} catch (error) {
console.log(error);
toast.error(messages?.error);
}
};
const onCancel = () => {
onClose();
resetStep();
};
return {
onConfirm,
onCancel,
};
};
@@ -0,0 +1,186 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Filter as FilterIcon, Search, Pencil } from 'lucide-react';
import {
Button,
Card,
CardContent,
CardHeader,
Checkbox,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Badge,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
Filter,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
useReactTable,
RowSelectionState,
} from '@tanstack/react-table';
import {
useUserList,
TUsersListItem,
} from '@imphnen-frontend-service/service';
export const Route = createFileRoute('/_authenticated/accounts')({
component: AccountsPage,
});
function AccountsPage() {
const navigate = useNavigate();
const [search, setSearch] = React.useState('');
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = React.useState(false);
const { data: usersData, isLoading } = useUserList({
search,
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
});
const users: TUsersListItem[] = usersData?.data ?? [];
const totalItems = usersData?.meta?.total ?? users.length;
const columns: ColumnDef<TUsersListItem>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={(v) =>
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="Select row"
/>
),
},
{ header: 'No', accessorKey: 'id' },
{ header: 'Nama Lengkap', accessorKey: 'fullname' },
{ header: 'Email', accessorKey: 'email' },
{ header: 'Role', accessorKey: 'role' },
{
header: 'Status',
accessorKey: 'is_active',
cell: ({ row }) => (
<Badge variant={row.original.is_active ? 'success' : 'destructive'}>
{row.original.is_active ? 'Aktif' : 'Tidak Aktif'}
</Badge>
),
},
{
header: 'Action',
cell: ({ row }) => (
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
navigate({ to: '/accounts/$id', params: { id: row.original.id } });
}}
>
<Pencil className="size-3.5" /> Edit
</Button>
),
},
];
const table = useReactTable({
data: users,
columns,
state: { pagination, rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true,
});
return (
<BackofficeWrapper
title="Data Akun"
description="Kelola akun pengguna yang terdaftar"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Cari nama lengkap atau email…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Popover open={showFilter} onOpenChange={setShowFilter}>
<PopoverTrigger asChild>
<Button variant="secondary" size="md">
<FilterIcon className="size-4" />
Filters
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-auto p-0">
<Filter
onClose={() => setShowFilter(false)}
options={[
{ id: 'all', value: 'all', label: 'Semua' },
{ id: 'active', value: 'active', label: 'Aktif' },
{ id: 'inactive', value: 'inactive', label: 'Tidak Aktif' },
]}
/>
</PopoverContent>
</Popover>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data
</div>
) : (
<DataTable
data={users}
columns={columns}
table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</CardContent>
</Card>
</BackofficeWrapper>
);
}
@@ -0,0 +1,122 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { toast } from 'sonner';
import { ArrowLeft } from 'lucide-react';
import {
Button,
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@imphnen-frontend-service/ui/atoms';
import { InputField } from '@imphnen-frontend-service/ui/molecules';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import {
useUserList,
useUpdateUserById,
} from '@imphnen-frontend-service/service';
export const Route = createFileRoute('/_authenticated/accounts_/$id')({
component: AccountsEditPage,
});
function AccountsEditPage() {
const { id } = Route.useParams();
const navigate = useNavigate();
const updateUser = useUpdateUserById();
const { data: usersData, isLoading } = useUserList({
search: '',
per_page: 100,
});
const user = usersData?.data?.find((u) => u.id === id);
const [fullName, setFullName] = React.useState('');
const [email, setEmail] = React.useState('');
React.useEffect(() => {
if (user) {
setFullName(user.fullname);
setEmail(user.email);
}
}, [user]);
const handleSubmit = async () => {
try {
await updateUser.mutateAsync({
id,
data: { fullname: fullName, email },
});
toast.success('Data akun berhasil diperbarui');
navigate({ to: '/accounts' });
} catch (error) {
console.log(error);
toast.error('Data akun gagal diperbarui');
}
};
return (
<BackofficeWrapper title="Edit Data Akun">
<div className="mx-auto w-full max-w-2xl">
<Button
variant="text"
size="sm"
onClick={() => navigate({ to: '/accounts' })}
className="mb-4 -ml-2"
>
<ArrowLeft className="size-4" />
Kembali
</Button>
<Card>
<CardHeader>
<CardTitle>Edit Data Akun</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data
</div>
) : (
<div className="flex flex-col gap-4">
<InputField
label="Nama Lengkap"
type="text"
placeholder="Masukkan nama lengkap"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
size="md"
/>
<InputField
label="Email"
type="text"
placeholder="Masukkan email"
value={email}
onChange={(e) => setEmail(e.target.value)}
size="md"
/>
</div>
)}
</CardContent>
<CardFooter className="justify-end gap-2">
<Button
variant="secondary"
size="md"
onClick={() => navigate({ to: '/accounts' })}
>
Batal
</Button>
<Button
variant="primary"
size="md"
onClick={handleSubmit}
disabled={updateUser.isPending || isLoading}
>
{updateUser.isPending ? 'Menyimpan…' : 'Perbarui Data'}
</Button>
</CardFooter>
</Card>
</div>
</BackofficeWrapper>
);
}
@@ -0,0 +1,250 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Badge,
Button,
Card,
CardContent,
CardHeader,
Checkbox,
Input,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import {
useEventList,
useDeleteEvent,
TEventsListItem,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
export const Route = createFileRoute('/_authenticated/cms-events')({
component: CmsEventsPage,
});
function CmsEventsPage() {
const navigate = useNavigate();
const [search, setSearch] = React.useState('');
const [deleteId, setDeleteId] = React.useState<string | null>(null);
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const { data: eventsData, isLoading } = useEventList({
search,
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
});
const deleteEvent = useDeleteEvent();
const events: TEventsListItem[] = eventsData?.data ?? [];
const totalItems = eventsData?.meta?.total ?? events.length;
const handleDelete = async (id: string) => {
try {
await deleteEvent.mutateAsync(id);
toast.success('Data event berhasil dihapus');
setDeleteId(null);
} catch (error) {
console.log(error);
toast.error('Data event gagal dihapus');
}
};
const columns: ColumnDef<TEventsListItem>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={(v) =>
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="Select row"
/>
),
},
{ header: 'Name', accessorKey: 'name' },
{
header: 'Location',
accessorKey: 'location',
cell: ({ row }) => row.original.location || '-',
},
{
header: 'Price',
accessorKey: 'price',
cell: ({ row }) =>
row.original.price === 0
? 'Free'
: `Rp ${row.original.price.toLocaleString('id-ID')}`,
},
{
header: 'Start Date',
accessorKey: 'start_date',
cell: ({ row }) =>
new Date(row.original.start_date).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
}),
},
{
header: 'Online',
accessorKey: 'is_online',
cell: ({ row }) => (
<Badge variant={row.original.is_online ? 'success' : 'secondary'}>
{row.original.is_online ? 'Online' : 'Offline'}
</Badge>
),
},
{
header: 'Action',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
navigate({
to: '/cms-events/$id',
params: { id: row.original.id },
});
}}
>
<Pencil className="size-3.5" />
Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setDeleteId(row.original.id);
}}
>
<Trash2 className="size-3.5" />
Delete
</Button>
</div>
),
},
];
const table = useReactTable({
data: events,
columns,
state: { pagination, rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true,
});
return (
<BackofficeWrapper title="CMS Events" description="Kelola event komunitas">
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Cari nama event…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button
onClick={() => navigate({ to: '/cms-events/create' })}
size="md"
>
<Plus className="size-4" />
Tambah Event
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data
</div>
) : (
<DataTable
data={events}
columns={columns}
table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</CardContent>
</Card>
<AlertDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus event ini?</AlertDialogTitle>
<AlertDialogDescription>
Tindakan ini tidak dapat dibatalkan. Event akan dihapus permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Batal</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteId && handleDelete(deleteId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Hapus
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</BackofficeWrapper>
);
}
@@ -0,0 +1,195 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'
import {
useEventList,
useUpdateEvent,
} from '@imphnen-frontend-service/service'
export const Route = createFileRoute('/_authenticated/cms-events_/$id')({
component: CmsEventsEditPage,
})
function CmsEventsEditPage() {
const { id } = Route.useParams()
const navigate = useNavigate()
const updateEvent = useUpdateEvent()
const { data: eventsData, isLoading } = useEventList({ search: '', per_page: 100 })
const event = eventsData?.data?.find((e) => e.id === id)
const form = useForm<{
name: string
description: string
detail_link: string
location: string
price: number
start_date: string
end_date: string
is_online: boolean
}>({
mode: 'all',
defaultValues: {
name: '',
description: '',
detail_link: '',
location: '',
price: 0,
start_date: '',
end_date: '',
is_online: false,
},
})
useEffect(() => {
if (event) {
form.reset({
name: event.name,
description: event.description,
detail_link: event.detail_link,
location: event.location,
price: event.price,
start_date: event.start_date,
end_date: event.end_date,
is_online: event.is_online,
})
}
}, [event])
const onSubmit = form.handleSubmit(async (data) => {
try {
await updateEvent.mutateAsync({ id, data })
toast.success('Perubahan event berhasil dilakukan')
navigate({ to: '/cms-events' })
} catch (error) {
console.log(error)
toast.error('Perubahan event gagal dilakukan')
}
})
if (isLoading) {
return (
<main className="w-full px-[48px] py-[40px]">
<div className="text-center py-8 text-neutral-400">Loading...</div>
</main>
)
}
return (
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<div className="max-w-2xl mx-auto w-full">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => navigate({ to: '/cms-events' })}
className="text-primary-500 hover:text-primary-600"
>
<ArrowLeftOutlined className="text-[20px]" />
</button>
<h1 className="text-2xl font-bold text-gray-900">Edit Event</h1>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<form onSubmit={onSubmit} className="flex flex-col gap-6">
<ControlledInputField
control={form.control}
label="Nama Event"
name="name"
type="text"
placeholder="Masukkan Nama Event"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Deskripsi"
name="description"
type="text"
placeholder="Masukkan Deskripsi Event"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Link Detail"
name="detail_link"
type="text"
placeholder="Masukkan Link Detail"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Lokasi"
name="location"
type="text"
placeholder="Masukkan Lokasi"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Harga"
name="price"
type="number"
placeholder="Masukkan Harga"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Tanggal Mulai"
name="start_date"
type="date"
placeholder="Pilih Tanggal Mulai"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Tanggal Selesai"
name="end_date"
type="date"
placeholder="Pilih Tanggal Selesai"
size="lg"
className="w-full"
/>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_online_update"
className="rounded"
{...form.register('is_online')}
/>
<label htmlFor="is_online_update" className="text-p3 font-medium text-neutral-800">
Event Online
</label>
</div>
<div className="flex gap-3 pt-4">
<Button
variant="primary"
size="lg"
className="w-full"
type="submit"
>
Update Event
</Button>
<Button
variant="bordered"
size="lg"
className="w-full"
onClick={() => navigate({ to: '/cms-events' })}
>
Batal
</Button>
</div>
</form>
</div>
</div>
</main>
)
}
@@ -0,0 +1,164 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'
import { useCreateEvent } from '@imphnen-frontend-service/service'
export const Route = createFileRoute('/_authenticated/cms-events_/create')({
component: CmsEventsCreatePage,
})
function CmsEventsCreatePage() {
const navigate = useNavigate()
const createEvent = useCreateEvent()
const form = useForm<{
name: string
description: string
detail_link: string
location: string
price: number
start_date: string
end_date: string
is_online: boolean
}>({
mode: 'all',
defaultValues: {
name: '',
description: '',
detail_link: '',
location: '',
price: 0,
start_date: '',
end_date: '',
is_online: false,
},
})
const onSubmit = form.handleSubmit(async (data) => {
try {
await createEvent.mutateAsync(data)
toast.success('Data event berhasil ditambahkan')
navigate({ to: '/cms-events' })
} catch (error) {
console.log(error)
toast.error('Data event gagal ditambahkan')
}
})
return (
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<div className="max-w-2xl mx-auto w-full">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => navigate({ to: '/cms-events' })}
className="text-primary-500 hover:text-primary-600"
>
<ArrowLeftOutlined className="text-[20px]" />
</button>
<h1 className="text-2xl font-bold text-gray-900">Tambah Event</h1>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<form onSubmit={onSubmit} className="flex flex-col gap-6">
<ControlledInputField
control={form.control}
label="Nama Event"
name="name"
type="text"
placeholder="Masukkan Nama Event"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Deskripsi"
name="description"
type="text"
placeholder="Masukkan Deskripsi Event"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Link Detail"
name="detail_link"
type="text"
placeholder="Masukkan Link Detail"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Lokasi"
name="location"
type="text"
placeholder="Masukkan Lokasi"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Harga"
name="price"
type="number"
placeholder="Masukkan Harga"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Tanggal Mulai"
name="start_date"
type="date"
placeholder="Pilih Tanggal Mulai"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Tanggal Selesai"
name="end_date"
type="date"
placeholder="Pilih Tanggal Selesai"
size="lg"
className="w-full"
/>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_online"
className="rounded"
{...form.register('is_online')}
/>
<label htmlFor="is_online" className="text-p3 font-medium text-neutral-800">
Event Online
</label>
</div>
<div className="flex gap-3 pt-4">
<Button
variant="primary"
size="lg"
className="w-full"
type="submit"
>
Tambah Event
</Button>
<Button
variant="bordered"
size="lg"
className="w-full"
onClick={() => navigate({ to: '/cms-events' })}
>
Batal
</Button>
</div>
</form>
</div>
</div>
</main>
)
}
@@ -0,0 +1,240 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Card,
CardContent,
CardHeader,
Checkbox,
Input,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import {
useTestimonialList,
useDeleteTestimonial,
TTestimonialsListItem,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
export const Route = createFileRoute('/_authenticated/cms-testimonials')({
component: CmsTestimonialsPage,
});
function CmsTestimonialsPage() {
const navigate = useNavigate();
const [search, setSearch] = React.useState('');
const [deleteId, setDeleteId] = React.useState<string | null>(null);
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const { data: testimonialsData, isLoading } = useTestimonialList({
search,
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
});
const deleteTestimonial = useDeleteTestimonial();
const testimonials: TTestimonialsListItem[] = testimonialsData?.data ?? [];
const totalItems = testimonialsData?.meta?.total ?? testimonials.length;
const handleDelete = async (id: string) => {
try {
await deleteTestimonial.mutateAsync(id);
toast.success('Data testimonial berhasil dihapus');
setDeleteId(null);
} catch (error) {
console.log(error);
toast.error('Data testimonial gagal dihapus');
}
};
const columns: ColumnDef<TTestimonialsListItem>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={(v) =>
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
/>
),
},
{ header: 'User', accessorKey: 'user_fullname' },
{ header: 'Role', accessorKey: 'role' },
{
header: 'Content',
accessorKey: 'content',
cell: ({ row }) => {
const content = row.original.content;
return content.length > 80
? `${content.substring(0, 80)}`
: content;
},
},
{
header: 'Created At',
accessorKey: 'created_at',
cell: ({ row }) =>
new Date(row.original.created_at).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
}),
},
{
header: 'Action',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
navigate({
to: '/cms-testimonials/$id',
params: { id: row.original.id },
});
}}
>
<Pencil className="size-3.5" />
Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setDeleteId(row.original.id);
}}
>
<Trash2 className="size-3.5" />
Delete
</Button>
</div>
),
},
];
const table = useReactTable({
data: testimonials,
columns,
state: { pagination, rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true,
});
return (
<BackofficeWrapper
title="CMS Testimonials"
description="Kelola testimonial pengguna"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Cari nama user…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button
onClick={() => navigate({ to: '/cms-testimonials/create' })}
size="md"
>
<Plus className="size-4" />
Tambah Testimonial
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data
</div>
) : (
<DataTable
data={testimonials}
columns={columns}
table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</CardContent>
</Card>
<AlertDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus testimonial ini?</AlertDialogTitle>
<AlertDialogDescription>
Tindakan ini tidak dapat dibatalkan. Testimonial akan dihapus
permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Batal</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteId && handleDelete(deleteId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Hapus
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</BackofficeWrapper>
);
}
@@ -0,0 +1,115 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'
import {
useTestimonialList,
useUpdateTestimonial,
} from '@imphnen-frontend-service/service'
export const Route = createFileRoute('/_authenticated/cms-testimonials_/$id')({
component: CmsTestimonialsEditPage,
})
function CmsTestimonialsEditPage() {
const { id } = Route.useParams()
const navigate = useNavigate()
const updateTestimonial = useUpdateTestimonial()
const { data: testimonialsData, isLoading } = useTestimonialList({ search: '', per_page: 100 })
const testimonial = testimonialsData?.data?.find((t) => t.id === id)
const form = useForm<{ role: string; content: string }>({
mode: 'all',
defaultValues: { role: '', content: '' },
})
useEffect(() => {
if (testimonial) {
form.reset({
role: testimonial.role,
content: testimonial.content,
})
}
}, [testimonial])
const onSubmit = form.handleSubmit(async (data) => {
try {
await updateTestimonial.mutateAsync({ id, data })
toast.success('Perubahan testimonial berhasil dilakukan')
navigate({ to: '/cms-testimonials' })
} catch (error) {
console.log(error)
toast.error('Perubahan testimonial gagal dilakukan')
}
})
if (isLoading) {
return (
<main className="w-full px-[48px] py-[40px]">
<div className="text-center py-8 text-neutral-400">Loading...</div>
</main>
)
}
return (
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<div className="max-w-2xl mx-auto w-full">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => navigate({ to: '/cms-testimonials' })}
className="text-primary-500 hover:text-primary-600"
>
<ArrowLeftOutlined className="text-[20px]" />
</button>
<h1 className="text-2xl font-bold text-gray-900">Edit Testimonial</h1>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<form onSubmit={onSubmit} className="flex flex-col gap-6">
<ControlledInputField
control={form.control}
label="Role"
name="role"
type="text"
placeholder="Masukkan Role"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Konten Testimonial"
name="content"
type="text"
placeholder="Masukkan Konten Testimonial"
size="lg"
className="w-full"
/>
<div className="flex gap-3 pt-4">
<Button
variant="primary"
size="lg"
className="w-full"
type="submit"
>
Update Testimonial
</Button>
<Button
variant="bordered"
size="lg"
className="w-full"
onClick={() => navigate({ to: '/cms-testimonials' })}
>
Batal
</Button>
</div>
</form>
</div>
</div>
</main>
)
}
@@ -0,0 +1,90 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'
import { useCreateTestimonial } from '@imphnen-frontend-service/service'
export const Route = createFileRoute('/_authenticated/cms-testimonials_/create')({
component: CmsTestimonialsCreatePage,
})
function CmsTestimonialsCreatePage() {
const navigate = useNavigate()
const createTestimonial = useCreateTestimonial()
const form = useForm<{ role: string; content: string }>({
mode: 'all',
defaultValues: { role: '', content: '' },
})
const onSubmit = form.handleSubmit(async (data) => {
try {
await createTestimonial.mutateAsync(data)
toast.success('Data testimonial berhasil ditambahkan')
navigate({ to: '/cms-testimonials' })
} catch (error) {
console.log(error)
toast.error('Data testimonial gagal ditambahkan')
}
})
return (
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<div className="max-w-2xl mx-auto w-full">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => navigate({ to: '/cms-testimonials' })}
className="text-primary-500 hover:text-primary-600"
>
<ArrowLeftOutlined className="text-[20px]" />
</button>
<h1 className="text-2xl font-bold text-gray-900">Tambah Testimonial</h1>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<form onSubmit={onSubmit} className="flex flex-col gap-6">
<ControlledInputField
control={form.control}
label="Role"
name="role"
type="text"
placeholder="Masukkan Role (e.g. Software Engineer)"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Konten Testimonial"
name="content"
type="text"
placeholder="Masukkan Konten Testimonial"
size="lg"
className="w-full"
/>
<div className="flex gap-3 pt-4">
<Button
variant="primary"
size="lg"
className="w-full"
type="submit"
>
Tambah Testimonial
</Button>
<Button
variant="bordered"
size="lg"
className="w-full"
onClick={() => navigate({ to: '/cms-testimonials' })}
>
Batal
</Button>
</div>
</form>
</div>
</div>
</main>
)
}
@@ -0,0 +1,212 @@
import { createFileRoute } from '@tanstack/react-router';
import * as React from 'react';
import { Users, UserCog, CalendarClock, Activity, CircleCheck } from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@imphnen-frontend-service/ui/atoms';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { UserGrowthChart } from './_components/dashboard-dimentorin/chart/user-growth';
import { SessionStatusChart } from './_components/dashboard-dimentorin/chart/session-status';
import {
useMentorList,
useUserList,
useMySessions,
} from '@imphnen-frontend-service/service';
export const Route = createFileRoute('/_authenticated/dashboard-dimentorin')({
component: DashboardDimentorinPage,
});
type StatCardProps = {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: React.ReactNode;
};
function StatCard({ icon: Icon, label, value }: StatCardProps) {
return (
<Card>
<CardContent className="flex items-center gap-4 pt-6">
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
<Icon className="size-5" />
</div>
<div className="flex flex-col">
<span className="text-2xl font-semibold leading-tight text-foreground">
{value}
</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
</CardContent>
</Card>
);
}
function DashboardDimentorinPage() {
const { data: mentorData } = useMentorList({
per_page: 5,
sort_by: 'rating',
order: 'desc',
});
const { data: userData } = useUserList({ per_page: 1 });
const { data: sessionsData } = useMySessions();
const totalMentors = mentorData?.meta?.total ?? 0;
const totalUsers = userData?.meta?.total ?? 0;
const totalSessions = sessionsData?.total ?? 0;
const topMentors = mentorData?.data ?? [];
const activeMentors = topMentors.filter((m) => m.status === 'active').length;
const completedSessions =
sessionsData?.sessions?.filter((s) => s.status === 'completed').length ?? 0;
const topTopics = React.useMemo(() => {
const sessions = sessionsData?.sessions ?? [];
const topicCount: Record<string, number> = {};
sessions.forEach((s) => {
topicCount[s.topic] = (topicCount[s.topic] ?? 0) + 1;
});
return Object.entries(topicCount)
.sort(([, a], [, b]) => b - a)
.slice(0, 5);
}, [sessionsData]);
return (
<BackofficeWrapper
title="Dimentorin Overview"
description="Ringkasan metrik platform mentoring"
>
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard icon={Users} label="Total Users" value={totalUsers} />
<StatCard icon={UserCog} label="Total Mentors" value={totalMentors} />
<StatCard
icon={CalendarClock}
label="Total Sessions"
value={totalSessions}
/>
<StatCard
icon={Activity}
label="Active Mentors"
value={activeMentors}
/>
<StatCard
icon={CircleCheck}
label="Completed Sessions"
value={completedSessions}
/>
</section>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-7">
<Card className="lg:col-span-5">
<CardHeader>
<CardTitle>User Growth</CardTitle>
<CardDescription>Pertumbuhan user dari waktu ke waktu</CardDescription>
</CardHeader>
<CardContent>
<UserGrowthChart />
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Session Status</CardTitle>
<CardDescription>Distribusi status sesi</CardDescription>
</CardHeader>
<CardContent>
<SessionStatusChart />
</CardContent>
</Card>
</section>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Top 5 Mentors</CardTitle>
<CardDescription>Mentor dengan rating tertinggi</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border border-neutral-200">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[10%]">No.</TableHead>
<TableHead>Nama Lengkap</TableHead>
<TableHead>Avg Rating</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topMentors.length === 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={3}
className="py-6 text-center text-sm text-muted-foreground"
>
Belum ada data
</TableCell>
</TableRow>
) : (
topMentors.slice(0, 5).map((mentor, index) => (
<TableRow key={mentor.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>{mentor.fullname ?? '-'}</TableCell>
<TableCell>
{mentor.rating?.toFixed(1) ?? '-'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Top Booked Topics</CardTitle>
<CardDescription>Topik mentoring paling banyak dibooking</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border border-neutral-200">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[10%]">No.</TableHead>
<TableHead>Topik</TableHead>
<TableHead>Total Sesi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topTopics.length === 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={3}
className="py-6 text-center text-sm text-muted-foreground"
>
Belum ada data
</TableCell>
</TableRow>
) : (
topTopics.map(([topic, count], index) => (
<TableRow key={topic}>
<TableCell>{index + 1}</TableCell>
<TableCell>{topic}</TableCell>
<TableCell>{count}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</section>
</BackofficeWrapper>
);
}
@@ -0,0 +1,185 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import {
Plus,
UsersRound,
UserMinus,
UserCog,
RefreshCcw,
Pencil,
Trash2,
MoreHorizontal,
} from 'lucide-react';
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@imphnen-frontend-service/ui/atoms';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import {
useUserList,
useGachaItemList,
useDeleteGachaItem,
TGachaItemDto,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import { DeleteConfirmDialog } from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/dashboard')({
component: DashboardPage,
});
type StatCardProps = {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: React.ReactNode;
};
function StatCard({ icon: Icon, label, value }: StatCardProps) {
return (
<Card>
<CardContent className="flex items-center gap-4 pt-6">
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
<Icon className="size-5" />
</div>
<div className="flex flex-col">
<span className="text-2xl font-semibold leading-tight text-foreground">
{value}
</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
</CardContent>
</Card>
);
}
function DashboardPage() {
const navigate = useNavigate();
const [deleteId, setDeleteId] = React.useState<string | null>(null);
const { data: usersData } = useUserList({ per_page: 1 });
const { data: gachaItemsData } = useGachaItemList({ per_page: 9 });
const deleteItem = useDeleteGachaItem();
const totalUsers = usersData?.meta?.total ?? 0;
const gachaItems: TGachaItemDto[] = gachaItemsData?.data ?? [];
const handleDelete = async (id: string) => {
try {
await deleteItem.mutateAsync(id);
toast.success('Item berhasil dihapus');
setDeleteId(null);
} catch (error) {
console.log(error);
toast.error('Item gagal dihapus');
}
};
return (
<BackofficeWrapper
title="Gacha Dashboard"
description="Ringkasan statistik & daftar item gacha"
>
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={UsersRound}
label="Participants"
value={totalUsers.toLocaleString('id-ID')}
/>
<StatCard
icon={RefreshCcw}
label="Gacha Items"
value={(gachaItemsData?.meta?.total ?? 0).toLocaleString('id-ID')}
/>
<StatCard icon={UserCog} label="Redeem" value="—" />
<StatCard icon={UserMinus} label="Inactive Users" value="—" />
</section>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Gacha Items</CardTitle>
<CardDescription>
Daftar item yang tersedia di gacha
</CardDescription>
</div>
<Button
size="md"
onClick={() => navigate({ to: '/dashboard/create' })}
>
<Plus className="size-4" />
Tambah Item
</Button>
</div>
</CardHeader>
<CardContent>
{gachaItems.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Belum ada item gacha.
</p>
) : (
<ul className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{gachaItems.map((item) => (
<li
key={item.id}
className="flex items-start justify-between gap-3 rounded-md border border-neutral-200 p-4 transition-colors hover:border-primary-200 hover:bg-primary-50/40"
>
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-semibold text-primary-700">
{item.name}
</h3>
<p className="mt-0.5 font-mono text-xs text-muted-foreground">
{item.id}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="text" size="icon" aria-label="Actions">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() =>
navigate({
to: '/dashboard/$id',
params: { id: item.id },
})
}
>
<Pencil />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => setDeleteId(item.id)}
>
<Trash2 />
<span>Hapus</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<DeleteConfirmDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
onConfirm={() => deleteId && handleDelete(deleteId)}
title="Hapus item gacha ini?"
/>
</BackofficeWrapper>
);
}

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