Merge branch 'dev' of https://git.profit-planet.partners/Seazn/profit-planet-frontend into dev
This commit is contained in:
commit
82364c1daa
BIN
public/images/icons/favicon_bw.ico
Normal file
BIN
public/images/icons/favicon_bw.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/images/icons/favicon_bw_round.ico
Normal file
BIN
public/images/icons/favicon_bw_round.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/images/icons/favicon_gold.ico
Normal file
BIN
public/images/icons/favicon_gold.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/images/logos/PP_Logo_BW.png
Normal file
BIN
public/images/logos/PP_Logo_BW.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
public/images/logos/PP_Logo_BW_round.png
Normal file
BIN
public/images/logos/PP_Logo_BW_round.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
49
src/app/admin/dev-management/hooks/executeSql.ts
Normal file
49
src/app/admin/dev-management/hooks/executeSql.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { authFetch } from '../../../utils/authFetch';
|
||||||
|
import { log } from '../../../utils/logger';
|
||||||
|
|
||||||
|
export type SqlExecutionData = {
|
||||||
|
result?: any;
|
||||||
|
isMulti?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SqlExecutionMeta = {
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function importSqlDump(file: File): Promise<{ ok: boolean; data?: SqlExecutionData; meta?: SqlExecutionMeta; message?: string }>{
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||||
|
const url = `${BASE_URL}/api/admin/dev/sql`;
|
||||||
|
|
||||||
|
log('🧪 Dev SQL Import: POST', url);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const res = await authFetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
|
||||||
|
let body: any = null;
|
||||||
|
try {
|
||||||
|
body = await res.clone().json();
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
return { ok: false, message: 'Unauthorized. Please log in.' };
|
||||||
|
}
|
||||||
|
if (res.status === 403) {
|
||||||
|
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
return { ok: false, message: body?.error || 'SQL execution failed.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, data: body?.data, meta: body?.meta };
|
||||||
|
} catch (e: any) {
|
||||||
|
log('❌ Dev SQL: network error', e?.message || e);
|
||||||
|
return { ok: false, message: 'Network error while executing SQL.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
197
src/app/admin/dev-management/page.tsx
Normal file
197
src/app/admin/dev-management/page.tsx
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import Header from '../../components/nav/Header'
|
||||||
|
import Footer from '../../components/Footer'
|
||||||
|
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||||
|
import { CommandLineIcon, PlayIcon, TrashIcon, ExclamationTriangleIcon, ArrowUpTrayIcon } from '@heroicons/react/24/outline'
|
||||||
|
import useAuthStore from '../../store/authStore'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { importSqlDump, SqlExecutionData, SqlExecutionMeta } from './hooks/executeSql'
|
||||||
|
|
||||||
|
export default function DevManagementPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const user = useAuthStore(s => s.user)
|
||||||
|
|
||||||
|
const isAdmin =
|
||||||
|
!!user &&
|
||||||
|
(
|
||||||
|
(user as any)?.role === 'admin' ||
|
||||||
|
(user as any)?.userType === 'admin' ||
|
||||||
|
(user as any)?.isAdmin === true ||
|
||||||
|
((user as any)?.roles?.includes?.('admin')) ||
|
||||||
|
(user as any)?.role === 'super_admin' ||
|
||||||
|
(user as any)?.userType === 'super_admin' ||
|
||||||
|
(user as any)?.isSuperAdmin === true ||
|
||||||
|
((user as any)?.roles?.includes?.('super_admin'))
|
||||||
|
)
|
||||||
|
|
||||||
|
const [authChecked, setAuthChecked] = React.useState(false)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (user === null) {
|
||||||
|
router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (user && !isAdmin) {
|
||||||
|
router.replace('/admin')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAuthChecked(true)
|
||||||
|
}, [user, isAdmin, router])
|
||||||
|
|
||||||
|
const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
|
||||||
|
const [loading, setLoading] = React.useState(false)
|
||||||
|
const [error, setError] = React.useState('')
|
||||||
|
const [result, setResult] = React.useState<SqlExecutionData | null>(null)
|
||||||
|
const [meta, setMeta] = React.useState<SqlExecutionMeta | null>(null)
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const runImport = async () => {
|
||||||
|
setError('')
|
||||||
|
if (!selectedFile) {
|
||||||
|
setError('Please select a SQL dump file.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await importSqlDump(selectedFile)
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(res.message || 'Failed to import SQL dump.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setResult(res.data || null)
|
||||||
|
setMeta(res.meta || null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearResults = () => {
|
||||||
|
setResult(null)
|
||||||
|
setMeta(null)
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setSelectedFile(file)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authChecked) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageTransitionEffect>
|
||||||
|
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 via-white to-blue-50">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<header className="bg-white/90 backdrop-blur border border-blue-100 py-8 px-6 rounded-2xl shadow-lg flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-12 w-12 rounded-xl bg-blue-100 border border-blue-200 flex items-center justify-center">
|
||||||
|
<CommandLineIcon className="h-6 w-6 text-blue-700" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-extrabold text-blue-900">Dev Management</h1>
|
||||||
|
<p className="text-blue-700">Import SQL dump files to run database migrations.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 flex items-start gap-2">
|
||||||
|
<ExclamationTriangleIcon className="h-5 w-5 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">Use with caution</div>
|
||||||
|
<div>SQL dumps run immediately and can modify production data.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="mt-8 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 rounded-2xl bg-white border border-gray-100 shadow-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-blue-900">SQL Dump Import</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<ArrowUpTrayIcon className="h-4 w-4" /> Import SQL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearResults}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" /> Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={runImport}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<PlayIcon className="h-4 w-4" /> {loading ? 'Importing...' : 'Import'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-dashed border-gray-200 bg-slate-50 px-6 py-10 text-center">
|
||||||
|
<div className="text-sm text-gray-600">Select a .sql dump file using Import SQL.</div>
|
||||||
|
<div className="mt-2 text-xs text-gray-500">Only SQL dump files are supported.</div>
|
||||||
|
{selectedFile && (
|
||||||
|
<div className="mt-4 text-sm text-blue-900 font-semibold">Selected: {selectedFile.name}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input ref={fileInputRef} type="file" accept=".sql,text/plain" className="hidden" onChange={onImportFile} />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-blue-900 mb-3">Result Summary</h3>
|
||||||
|
<div className="space-y-2 text-sm text-gray-700">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Result Sets</span>
|
||||||
|
<span className="font-semibold text-blue-900">
|
||||||
|
{Array.isArray(result?.result) ? (result?.result as any[]).length : result?.result ? 1 : 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Multi-statement</span>
|
||||||
|
<span className="font-semibold text-blue-900">{result?.isMulti ? 'Yes' : 'No'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Duration</span>
|
||||||
|
<span className="font-semibold text-blue-900">{meta?.durationMs ? `${meta.durationMs} ms` : '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 text-xs text-gray-500">
|
||||||
|
Multi-statement SQL and dump files are supported. Use with caution.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-blue-900">Import Results</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!result && (
|
||||||
|
<div className="text-sm text-gray-500">No results yet. Import a SQL dump to see output.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result?.result && (
|
||||||
|
<pre className="mt-2 rounded-lg bg-slate-50 border border-gray-200 p-4 text-xs text-gray-800 overflow-auto">
|
||||||
|
{JSON.stringify(result.result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</PageTransitionEffect>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -11,20 +11,36 @@ import {
|
|||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
Squares2X2Icon,
|
Squares2X2Icon,
|
||||||
BanknotesIcon,
|
BanknotesIcon,
|
||||||
ClipboardDocumentListIcon
|
ClipboardDocumentListIcon,
|
||||||
|
CommandLineIcon
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import { useMemo, useState, useEffect } from 'react'
|
import { useMemo, useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useAdminUsers } from '../hooks/useAdminUsers'
|
import { useAdminUsers } from '../hooks/useAdminUsers'
|
||||||
|
import useAuthStore from '../store/authStore'
|
||||||
|
|
||||||
// env-based feature flags
|
// env-based feature flags
|
||||||
const DISPLAY_MATRIX = process.env.NEXT_PUBLIC_DISPLAY_MATRIX !== 'false'
|
const DISPLAY_MATRIX = process.env.NEXT_PUBLIC_DISPLAY_MATRIX !== 'false'
|
||||||
const DISPLAY_ABONEMENTS = process.env.NEXT_PUBLIC_DISPLAY_ABONEMMENTS !== 'false'
|
const DISPLAY_ABONEMENTS = process.env.NEXT_PUBLIC_DISPLAY_ABONEMMENTS !== 'false'
|
||||||
const DISPLAY_NEWS = process.env.NEXT_PUBLIC_DISPLAY_NEWS !== 'false'
|
const DISPLAY_NEWS = process.env.NEXT_PUBLIC_DISPLAY_NEWS !== 'false'
|
||||||
|
const DISPLAY_DEV_MANAGEMENT = process.env.NEXT_PUBLIC_DISPLAY_DEV_MANAGEMENT !== 'false'
|
||||||
|
|
||||||
export default function AdminDashboardPage() {
|
export default function AdminDashboardPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { userStats, isAdmin } = useAdminUsers()
|
const { userStats, isAdmin } = useAdminUsers()
|
||||||
|
const user = useAuthStore(s => s.user)
|
||||||
|
const isAdminOrSuper =
|
||||||
|
!!user &&
|
||||||
|
(
|
||||||
|
(user as any)?.role === 'admin' ||
|
||||||
|
(user as any)?.userType === 'admin' ||
|
||||||
|
(user as any)?.isAdmin === true ||
|
||||||
|
((user as any)?.roles?.includes?.('admin')) ||
|
||||||
|
(user as any)?.role === 'super_admin' ||
|
||||||
|
(user as any)?.userType === 'super_admin' ||
|
||||||
|
(user as any)?.isSuperAdmin === true ||
|
||||||
|
((user as any)?.roles?.includes?.('super_admin'))
|
||||||
|
)
|
||||||
const [isClient, setIsClient] = useState(false)
|
const [isClient, setIsClient] = useState(false)
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
|
|
||||||
@ -318,6 +334,49 @@ export default function AdminDashboardPage() {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Dev Management */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!DISPLAY_DEV_MANAGEMENT || !isAdminOrSuper}
|
||||||
|
onClick={DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? () => router.push('/admin/dev-management') : undefined}
|
||||||
|
className={`group w-full flex items-center justify-between rounded-lg px-4 py-4 ${
|
||||||
|
DISPLAY_DEV_MANAGEMENT && isAdminOrSuper
|
||||||
|
? 'border border-slate-200 bg-slate-50 hover:bg-slate-100 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md'
|
||||||
|
: 'border border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-10 w-10 items-center justify-center rounded-md border ${
|
||||||
|
DISPLAY_DEV_MANAGEMENT && isAdminOrSuper
|
||||||
|
? 'bg-slate-100 border-slate-200 group-hover:animate-pulse'
|
||||||
|
: 'bg-gray-100 border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CommandLineIcon className={`h-6 w-6 ${DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? 'text-slate-600' : 'text-gray-400'}`} />
|
||||||
|
</span>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-base font-semibold text-slate-900">Dev Management</div>
|
||||||
|
<div className="text-xs text-slate-700">Run SQL queries and dev tools</div>
|
||||||
|
{!DISPLAY_DEV_MANAGEMENT && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500 italic">
|
||||||
|
This module is currently disabled in the system configuration.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{DISPLAY_DEV_MANAGEMENT && !isAdminOrSuper && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500 italic">
|
||||||
|
Admin access required.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRightIcon
|
||||||
|
className={`h-5 w-5 ${
|
||||||
|
DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? 'text-slate-600 opacity-70 group-hover:opacity-100' : 'text-gray-400 opacity-60'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import PageLayout from '../../components/PageLayout'
|
|||||||
import UserDetailModal from '../../components/UserDetailModal'
|
import UserDetailModal from '../../components/UserDetailModal'
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
CheckIcon,
|
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
EyeIcon
|
EyeIcon
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
@ -14,14 +13,14 @@ import { PendingUser } from '../../utils/api'
|
|||||||
|
|
||||||
type UserType = 'personal' | 'company'
|
type UserType = 'personal' | 'company'
|
||||||
type UserRole = 'user' | 'admin'
|
type UserRole = 'user' | 'admin'
|
||||||
|
type VerificationReadyFilter = 'all' | 'ready' | 'not_ready'
|
||||||
|
type StatusFilter = 'all' | 'pending' | 'verifying' | 'active'
|
||||||
|
|
||||||
export default function AdminUserVerifyPage() {
|
export default function AdminUserVerifyPage() {
|
||||||
const {
|
const {
|
||||||
pendingUsers,
|
pendingUsers,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
verifying,
|
|
||||||
verifyUser: handleVerifyUser,
|
|
||||||
isAdmin,
|
isAdmin,
|
||||||
fetchPendingUsers
|
fetchPendingUsers
|
||||||
} = useAdminUsers()
|
} = useAdminUsers()
|
||||||
@ -34,6 +33,8 @@ export default function AdminUserVerifyPage() {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [fType, setFType] = useState<'all' | UserType>('all')
|
const [fType, setFType] = useState<'all' | UserType>('all')
|
||||||
const [fRole, setFRole] = useState<'all' | UserRole>('all')
|
const [fRole, setFRole] = useState<'all' | UserRole>('all')
|
||||||
|
const [fReady, setFReady] = useState<VerificationReadyFilter>('all')
|
||||||
|
const [fStatus, setFStatus] = useState<StatusFilter>('all')
|
||||||
const [perPage, setPerPage] = useState(10)
|
const [perPage, setPerPage] = useState(10)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
@ -44,10 +45,18 @@ export default function AdminUserVerifyPage() {
|
|||||||
const lastName = u.last_name || ''
|
const lastName = u.last_name || ''
|
||||||
const companyName = u.company_name || ''
|
const companyName = u.company_name || ''
|
||||||
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
|
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
|
||||||
|
const isReadyToVerify = u.email_verified === 1 && u.profile_completed === 1 &&
|
||||||
|
u.documents_uploaded === 1 && u.contract_signed === 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(fType === 'all' || u.user_type === fType) &&
|
(fType === 'all' || u.user_type === fType) &&
|
||||||
(fRole === 'all' || u.role === fRole) &&
|
(fRole === 'all' || u.role === fRole) &&
|
||||||
|
(fStatus === 'all' || u.status === fStatus) &&
|
||||||
|
(
|
||||||
|
fReady === 'all' ||
|
||||||
|
(fReady === 'ready' && isReadyToVerify) ||
|
||||||
|
(fReady === 'not_ready' && !isReadyToVerify)
|
||||||
|
) &&
|
||||||
(
|
(
|
||||||
!search.trim() ||
|
!search.trim() ||
|
||||||
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
@ -55,7 +64,7 @@ export default function AdminUserVerifyPage() {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [pendingUsers, search, fType, fRole])
|
}, [pendingUsers, search, fType, fRole, fReady, fStatus])
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage))
|
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage))
|
||||||
const current = filtered.slice((page - 1) * perPage, page * perPage)
|
const current = filtered.slice((page - 1) * perPage, page * perPage)
|
||||||
@ -178,9 +187,9 @@ export default function AdminUserVerifyPage() {
|
|||||||
<h2 className="text-lg font-semibold text-blue-900">
|
<h2 className="text-lg font-semibold text-blue-900">
|
||||||
Search & Filter Pending Users
|
Search & Filter Pending Users
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-6 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-7 gap-4">
|
||||||
<div className="md:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<label className="sr-only">Search</label>
|
<label className="block text-xs font-semibold text-blue-900 mb-1">Search</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-blue-300" />
|
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-blue-300" />
|
||||||
<input
|
<input
|
||||||
@ -192,6 +201,7 @@ export default function AdminUserVerifyPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-blue-900 mb-1">User Type</label>
|
||||||
<select
|
<select
|
||||||
value={fType}
|
value={fType}
|
||||||
onChange={e => { setFType(e.target.value as any); setPage(1) }}
|
onChange={e => { setFType(e.target.value as any); setPage(1) }}
|
||||||
@ -203,6 +213,7 @@ export default function AdminUserVerifyPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-blue-900 mb-1">Role</label>
|
||||||
<select
|
<select
|
||||||
value={fRole}
|
value={fRole}
|
||||||
onChange={e => { setFRole(e.target.value as any); setPage(1) }}
|
onChange={e => { setFRole(e.target.value as any); setPage(1) }}
|
||||||
@ -214,6 +225,32 @@ export default function AdminUserVerifyPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-blue-900 mb-1">Verification Readiness</label>
|
||||||
|
<select
|
||||||
|
value={fReady}
|
||||||
|
onChange={e => { setFReady(e.target.value as VerificationReadyFilter); setPage(1) }}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||||||
|
>
|
||||||
|
<option value="all">All Readiness</option>
|
||||||
|
<option value="ready">Ready to Verify</option>
|
||||||
|
<option value="not_ready">Not Ready</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-blue-900 mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={fStatus}
|
||||||
|
onChange={e => { setFStatus(e.target.value as StatusFilter); setPage(1) }}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="verifying">Verifying</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-blue-900 mb-1">Rows per page</label>
|
||||||
<select
|
<select
|
||||||
value={perPage}
|
value={perPage}
|
||||||
onChange={e => { setPerPage(parseInt(e.target.value, 10)); setPage(1) }}
|
onChange={e => { setPerPage(parseInt(e.target.value, 10)); setPage(1) }}
|
||||||
@ -222,14 +259,6 @@ export default function AdminUserVerifyPage() {
|
|||||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-stretch">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full inline-flex items-center justify-center rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 text-sm font-semibold px-5 py-3 shadow transition"
|
|
||||||
>
|
|
||||||
Filter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@ -275,13 +304,8 @@ export default function AdminUserVerifyPage() {
|
|||||||
? (u.company_name?.[0] || 'C').toUpperCase()
|
? (u.company_name?.[0] || 'C').toUpperCase()
|
||||||
: `${u.first_name?.[0] || 'U'}${u.last_name?.[0] || 'U'}`.toUpperCase()
|
: `${u.first_name?.[0] || 'U'}${u.last_name?.[0] || 'U'}`.toUpperCase()
|
||||||
|
|
||||||
const isVerifying = verifying.has(u.id.toString())
|
|
||||||
const createdDate = new Date(u.created_at).toLocaleDateString()
|
const createdDate = new Date(u.created_at).toLocaleDateString()
|
||||||
|
|
||||||
// Check if user has completed all verification steps
|
|
||||||
const isReadyToVerify = u.email_verified === 1 && u.profile_completed === 1 &&
|
|
||||||
u.documents_uploaded === 1 && u.contract_signed === 1
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={u.id} className="hover:bg-blue-50">
|
<tr key={u.id} className="hover:bg-blue-50">
|
||||||
<td className="px-4 py-4">
|
<td className="px-4 py-4">
|
||||||
@ -314,30 +338,6 @@ export default function AdminUserVerifyPage() {
|
|||||||
<EyeIcon className="h-4 w-4" /> View
|
<EyeIcon className="h-4 w-4" /> View
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isReadyToVerify ? (
|
|
||||||
<button
|
|
||||||
onClick={() => handleVerifyUser(u.id.toString())}
|
|
||||||
disabled={isVerifying}
|
|
||||||
className={`inline-flex items-center gap-1 rounded-lg border px-3 py-2 text-xs font-medium transition
|
|
||||||
${isVerifying
|
|
||||||
? 'border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
||||||
: 'border-emerald-200 bg-emerald-50 hover:bg-emerald-100 text-emerald-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isVerifying ? (
|
|
||||||
<>
|
|
||||||
<span className="h-3 w-3 rounded-full border-2 border-emerald-500 border-b-transparent animate-spin" />
|
|
||||||
Verifying...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="h-4 w-4" /> Verify
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-gray-500 italic">Incomplete steps</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -387,6 +387,9 @@ export default function AdminUserVerifyPage() {
|
|||||||
setSelectedUserId(null)
|
setSelectedUserId(null)
|
||||||
}}
|
}}
|
||||||
userId={selectedUserId}
|
userId={selectedUserId}
|
||||||
|
onUserUpdated={() => {
|
||||||
|
fetchPendingUsers()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -47,7 +47,6 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
const token = useAuthStore(state => state.accessToken)
|
const token = useAuthStore(state => state.accessToken)
|
||||||
|
|
||||||
// Contract preview state (lazy-loaded, per contract type)
|
// Contract preview state (lazy-loaded, per contract type)
|
||||||
const [previewLoading, setPreviewLoading] = useState(false)
|
|
||||||
const [activePreviewTab, setActivePreviewTab] = useState<'contract' | 'gdpr'>('contract')
|
const [activePreviewTab, setActivePreviewTab] = useState<'contract' | 'gdpr'>('contract')
|
||||||
const [previewState, setPreviewState] = useState({
|
const [previewState, setPreviewState] = useState({
|
||||||
contract: { loading: false, html: null as string | null, error: null as string | null },
|
contract: { loading: false, html: null as string | null, error: null as string | null },
|
||||||
@ -88,17 +87,6 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load both contract and GDPR previews when modal opens after user is known
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen || !userId || !token || !userDetails) return
|
|
||||||
setPreviewLoading(true)
|
|
||||||
Promise.all([
|
|
||||||
loadContractPreview('contract'),
|
|
||||||
loadContractPreview('gdpr')
|
|
||||||
]).finally(() => setPreviewLoading(false))
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [isOpen, userId, token, userDetails])
|
|
||||||
|
|
||||||
const handleStatusChange = async (newStatus: UserStatus) => {
|
const handleStatusChange = async (newStatus: UserStatus) => {
|
||||||
if (!userId || !token || newStatus === selectedStatus) return
|
if (!userId || !token || newStatus === selectedStatus) return
|
||||||
|
|
||||||
@ -439,11 +427,11 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => Promise.all([loadContractPreview('contract'), loadContractPreview('gdpr')])}
|
onClick={() => loadContractPreview(activePreviewTab)}
|
||||||
disabled={previewLoading || previewState.contract.loading || previewState.gdpr.loading}
|
disabled={previewState[activePreviewTab].loading}
|
||||||
className="inline-flex items-center justify-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 text-sm disabled:opacity-60"
|
className="inline-flex items-center justify-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 text-sm disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{previewLoading || previewState.contract.loading || previewState.gdpr.loading ? 'Loading…' : 'Refresh'}
|
{previewState[activePreviewTab].loading ? 'Loading…' : 'Preview'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -467,12 +455,12 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
{previewState[activePreviewTab].error}
|
{previewState[activePreviewTab].error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(previewLoading || previewState[activePreviewTab].loading) && (
|
{previewState[activePreviewTab].loading && (
|
||||||
<div className="flex items-center justify-center h-40 text-sm text-gray-500">
|
<div className="flex items-center justify-center h-40 text-sm text-gray-500">
|
||||||
Loading preview…
|
Loading preview…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!previewLoading && !previewState[activePreviewTab].loading && previewState[activePreviewTab].html && (
|
{!previewState[activePreviewTab].loading && previewState[activePreviewTab].html && (
|
||||||
<div className="rounded-md border border-gray-200 overflow-hidden">
|
<div className="rounded-md border border-gray-200 overflow-hidden">
|
||||||
<iframe
|
<iframe
|
||||||
title={`Contract Preview ${activePreviewTab}`}
|
title={`Contract Preview ${activePreviewTab}`}
|
||||||
@ -481,8 +469,8 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!previewLoading && !previewState[activePreviewTab].loading && !previewState[activePreviewTab].html && !previewState[activePreviewTab].error && (
|
{!previewState[activePreviewTab].loading && !previewState[activePreviewTab].html && !previewState[activePreviewTab].error && (
|
||||||
<p className="text-sm text-gray-500">Click "Refresh" to render the latest active contract or GDPR template for this user.</p>
|
<p className="text-sm text-gray-500">Click “Preview” to render the latest template for this user.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -327,6 +327,18 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
)
|
)
|
||||||
const isAdmin = mounted && rawIsAdmin
|
const isAdmin = mounted && rawIsAdmin
|
||||||
|
|
||||||
|
const rawIsSuperAdmin =
|
||||||
|
!!user &&
|
||||||
|
(
|
||||||
|
(user as any)?.role === 'super_admin' ||
|
||||||
|
(user as any)?.userType === 'super_admin' ||
|
||||||
|
(user as any)?.isSuperAdmin === true ||
|
||||||
|
((user as any)?.roles?.includes?.('super_admin'))
|
||||||
|
)
|
||||||
|
const isSuperAdmin = mounted && rawIsSuperAdmin
|
||||||
|
|
||||||
|
const isAdminOrSuper = mounted && (rawIsAdmin || rawIsSuperAdmin)
|
||||||
|
|
||||||
// Only gate visibility by scroll on parallax-enabled pages
|
// Only gate visibility by scroll on parallax-enabled pages
|
||||||
const headerVisible = parallaxEnabled ? animateIn && scrollY > 24 : animateIn
|
const headerVisible = parallaxEnabled ? animateIn && scrollY > 24 : animateIn
|
||||||
const parallaxOffset = parallaxEnabled ? Math.max(-16, -scrollY * 0.15) : 0
|
const parallaxOffset = parallaxEnabled ? Math.max(-16, -scrollY * 0.15) : 0
|
||||||
@ -656,6 +668,16 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
News Management
|
News Management
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAdminOrSuper && (
|
||||||
|
<button
|
||||||
|
onClick={() => { router.push('/admin/dev-management'); setAdminMgmtOpen(false); }}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-[#0F1D37] hover:bg-[#F5F3EE]"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
Dev Management
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -15,8 +15,11 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Profit Planet",
|
||||||
description: "Generated by create next app",
|
description: "Generated by create next app",
|
||||||
|
icons: {
|
||||||
|
icon: "/images/icons/favicon_bw_round.ico",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user