feat: implement dynamic knowledge base management and seeding
This commit is contained in:
parent
9bfaa2279c
commit
ac146c4d36
6 changed files with 358 additions and 888 deletions
|
|
@ -3,7 +3,7 @@ import type { RuntimeDashboardLayout } from './types';
|
|||
// All API calls go through the server route gateway proxy
|
||||
const API_GATEWAY = '/api/gateway';
|
||||
|
||||
export type RuntimeRecordType = 'role' | 'dashboard' | 'onboarding';
|
||||
export type RuntimeRecordType = 'role' | 'dashboard' | 'onboarding' | 'knowledge_base';
|
||||
export type RuntimeRecordStatus = 'draft' | 'published';
|
||||
|
||||
export type RuntimeStoredRecord<T> = {
|
||||
|
|
|
|||
|
|
@ -65,3 +65,18 @@ export type RuntimeOnboardingConfig = {
|
|||
version: number;
|
||||
steps: RuntimeOnboardingStep[];
|
||||
};
|
||||
|
||||
export type RuntimeKBArticle = {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
content: string;
|
||||
author: string;
|
||||
isPublished: boolean;
|
||||
viewCount: number;
|
||||
lastUpdated: string;
|
||||
};
|
||||
|
||||
export type RuntimeKBConfig = {
|
||||
articles: RuntimeKBArticle[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -265,7 +265,7 @@ export default function ApprovalManagementPage() {
|
|||
const accessToken = typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
|
||||
: '';
|
||||
const res = await fetch(`${API}/api/admin/approvals?page=1&limit=100`, {
|
||||
const res = await fetch(`${API}/api/admin/verifications?page=1&limit=100`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
|
|
@ -274,108 +274,33 @@ export default function ApprovalManagementPage() {
|
|||
});
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||
const payload = await res.json().catch(() => ({} as any));
|
||||
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||
|
||||
const jobs = Array.isArray(payload?.jobs) ? payload.jobs : [];
|
||||
const requirements = Array.isArray(payload?.requirements) ? payload.requirements : [];
|
||||
const profiles = Array.isArray(payload?.profiles) ? payload.profiles : [];
|
||||
const portfolios = Array.isArray(payload?.portfolios) ? payload.portfolios : [];
|
||||
|
||||
const mappedJobs: ApprovalRecord[] = jobs.map((job: any) => ({
|
||||
id: `AP-J-${String(job.id || Math.random()).slice(0, 8)}`,
|
||||
name: `Job Approval - ${String(job.title || 'Untitled Job')}`,
|
||||
applicantName: String(job.company_name || job.created_by_name || job.title || 'Company Applicant'),
|
||||
approvalType: 'JOB',
|
||||
userType: 'COMPANY',
|
||||
roleTags: extractRoleTags(job),
|
||||
primaryService: String(job.category || job.department || job.role || 'Job Posting'),
|
||||
area: String(job.location || job.city || job.work_mode || 'Chennai'),
|
||||
submittedDate: String(job.created_at || ''),
|
||||
verificationStatus: 'VERIFIED',
|
||||
assignedApprover: 'Unassigned',
|
||||
priority: 'HIGH',
|
||||
status: 'PENDING',
|
||||
updatedAt: String(job.updated_at || job.created_at || ''),
|
||||
sourceKey: `jobs:${String(job.id || '')}`,
|
||||
submittedFields: extractSubmittedFields(job),
|
||||
documents: extractDocuments(job),
|
||||
payload: job,
|
||||
}));
|
||||
|
||||
const mappedReqs: ApprovalRecord[] = requirements.map((req: any) => ({
|
||||
id: `AP-R-${String(req.id || Math.random()).slice(0, 8)}`,
|
||||
name: `Requirement Approval - ${String(req.title || 'Untitled Requirement')}`,
|
||||
applicantName: String(req.created_by_name || req.customer_name || req.title || 'Service Seeker Applicant'),
|
||||
approvalType: 'REQUIREMENT',
|
||||
userType: 'CUSTOMER',
|
||||
roleTags: extractRoleTags(req),
|
||||
primaryService: String(req.category || req.profession || req.service_type || 'Requirement'),
|
||||
area: String(req.location || req.city || req.area || 'Chennai'),
|
||||
submittedDate: String(req.created_at || ''),
|
||||
verificationStatus: 'VERIFIED',
|
||||
assignedApprover: 'Unassigned',
|
||||
priority: 'MEDIUM',
|
||||
status: 'PENDING',
|
||||
updatedAt: String(req.updated_at || req.created_at || ''),
|
||||
sourceKey: `requirements:${String(req.id || '')}`,
|
||||
submittedFields: extractSubmittedFields(req),
|
||||
documents: extractDocuments(req),
|
||||
payload: req,
|
||||
}));
|
||||
|
||||
const mappedProfiles: ApprovalRecord[] = profiles.map((profile: any) => ({
|
||||
id: `AP-P-${String(profile.id || profile.user_id || Math.random()).slice(0, 8)}`,
|
||||
name: `Profile Approval - ${String(profile.full_name || profile.name || 'Applicant')}`,
|
||||
applicantName: String(profile.full_name || profile.name || profile.user_name || 'Applicant'),
|
||||
approvalType: profile.role_key === 'COMPANY' ? 'BUSINESS' : 'PROFILE',
|
||||
userType: normalizeUserType(profile.role_key || profile.roleKey || profile.user_type),
|
||||
roleTags: extractRoleTags(profile),
|
||||
primaryService: String(profile.role_key || profile.role || profile.category || 'Profile'),
|
||||
area: String(profile.area || profile.place || profile.city || 'Chennai'),
|
||||
submittedDate: String(profile.submitted_at || profile.created_at || ''),
|
||||
verificationStatus: 'VERIFIED',
|
||||
assignedApprover: 'Unassigned',
|
||||
priority: 'MEDIUM',
|
||||
status: 'PENDING',
|
||||
updatedAt: String(profile.updated_at || profile.created_at || ''),
|
||||
sourceKey: `profiles:${String(profile.id || profile.user_id || '')}`,
|
||||
submittedFields: extractSubmittedFields(profile),
|
||||
documents: extractDocuments(profile),
|
||||
payload: profile,
|
||||
}));
|
||||
|
||||
const mappedPortfolios: ApprovalRecord[] = portfolios.map((portfolio: any) => ({
|
||||
id: `AP-F-${String(portfolio.id || portfolio.user_id || Math.random()).slice(0, 8)}`,
|
||||
name: `Portfolio Approval - ${String(portfolio.full_name || portfolio.name || 'Professional')}`,
|
||||
applicantName: String(portfolio.full_name || portfolio.name || 'Professional'),
|
||||
approvalType: 'PORTFOLIO',
|
||||
userType: 'PROFESSIONAL',
|
||||
roleTags: extractRoleTags(portfolio),
|
||||
primaryService: String(portfolio.role_key || portfolio.role || 'Professional Portfolio'),
|
||||
area: String(portfolio.area || portfolio.place || portfolio.city || 'Chennai'),
|
||||
submittedDate: String(portfolio.submitted_at || portfolio.created_at || ''),
|
||||
verificationStatus: 'VERIFIED',
|
||||
assignedApprover: 'Unassigned',
|
||||
priority: 'HIGH',
|
||||
status: 'PENDING',
|
||||
updatedAt: String(portfolio.updated_at || portfolio.created_at || ''),
|
||||
sourceKey: `portfolios:${String(portfolio.id || portfolio.user_id || '')}`,
|
||||
submittedFields: extractSubmittedFields(portfolio),
|
||||
documents: extractDocuments(portfolio),
|
||||
payload: portfolio,
|
||||
}));
|
||||
|
||||
const queueRaw = typeof window !== 'undefined' ? window.localStorage.getItem(APPROVAL_QUEUE_STORAGE_KEY) : null;
|
||||
const queueParsed = queueRaw ? JSON.parse(queueRaw) : [];
|
||||
const queueItems = Array.isArray(queueParsed) ? queueParsed as ApprovalQueueItem[] : [];
|
||||
const mappedQueue = mapQueueItemsToApprovals(queueItems);
|
||||
|
||||
const merged = [...mappedQueue, ...mappedJobs, ...mappedReqs, ...mappedProfiles, ...mappedPortfolios];
|
||||
const deduped = merged.filter((row, index, arr) => {
|
||||
const key = row.sourceKey || row.id;
|
||||
return arr.findIndex((candidate) => (candidate.sourceKey || candidate.id) === key) === index;
|
||||
const mappedItems: ApprovalRecord[] = items.map((v: any) => {
|
||||
const p = v.payload || {};
|
||||
return {
|
||||
id: v.id,
|
||||
name: `${toTitle(v.type)} - ${v.user_name || 'Applicant'}`,
|
||||
applicantName: v.user_name || 'Applicant',
|
||||
approvalType: verificationToApprovalType(v.type === 'job_approval' ? 'Job Approval' : (v.type === 'requirement_approval' ? 'Service Seeker Requirement' : 'Profile Approval')),
|
||||
userType: normalizeUserType(v.role_key),
|
||||
roleTags: [toTitle(v.role_key)],
|
||||
primaryService: toTitle(v.role_key || 'User'),
|
||||
area: p.city || p.area || 'Unknown',
|
||||
submittedDate: v.created_at,
|
||||
verificationStatus: v.status === 'APPROVED' ? 'VERIFIED' : 'PENDING',
|
||||
assignedApprover: 'Unassigned',
|
||||
priority: 'MEDIUM',
|
||||
status: v.status === 'APPROVED' ? 'APPROVED' : (v.status === 'REJECTED' ? 'REJECTED' : 'PENDING'),
|
||||
updatedAt: v.updated_at,
|
||||
sourceKey: `v:${v.id}`,
|
||||
submittedFields: extractSubmittedFields(p),
|
||||
documents: extractDocuments(p),
|
||||
payload: v,
|
||||
};
|
||||
});
|
||||
|
||||
setRows(deduped);
|
||||
|
||||
setRows(mappedItems);
|
||||
} catch (e: any) {
|
||||
setRows([]);
|
||||
setError(e?.message || 'Could not reach approvals API.');
|
||||
|
|
@ -476,9 +401,7 @@ export default function ApprovalManagementPage() {
|
|||
const accessToken = typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
|
||||
: '';
|
||||
const endpoint = type === 'JOB'
|
||||
? `${API}/api/admin/approvals/jobs/${String(row.id).replace(/^AP-J-/, '')}/${action}`
|
||||
: `${API}/api/admin/approvals/requirements/${String(row.id).replace(/^AP-R-/, '')}/${action}`;
|
||||
const endpoint = `${API}/api/admin/verifications/${row.id}/${action}`;
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -61,11 +61,8 @@ function normalizeExternalRole(item: any, index: number): ExternalRoleRecord {
|
|||
|
||||
const USER_TYPE_OPTIONS = ['COMPANY', 'CANDIDATE', 'PHOTOGRAPHER', 'MAKEUP_ARTIST', 'TUTOR', 'DEVELOPER', 'VIDEO_EDITOR', 'UGC_CONTENT_CREATOR', 'FITNESS_TRAINER', 'CATERER', 'GRAPHIC_DESIGNER', 'SOCIAL_MEDIA_MANAGER', 'CUSTOMER'];
|
||||
|
||||
const ONBOARDING_SCHEMAS = [
|
||||
'company_onboarding_v1', 'job_seeker_onboarding_v1', 'customer_onboarding_v1', 'photographer_onboarding_v1',
|
||||
'makeup_artist_onboarding_v1', 'tutor_onboarding_v1', 'developer_onboarding_v1', 'video_editor_onboarding_v1',
|
||||
'ugc_content_creator_onboarding_v1'
|
||||
];
|
||||
// Onboarding schemas removed in favor of Dashboard-first profile completion.
|
||||
const ONBOARDING_SCHEMAS: string[] = [];
|
||||
|
||||
const MODULES_BY_VERTICAL = {
|
||||
jobs: [
|
||||
|
|
@ -103,17 +100,9 @@ function StatusBadge(props: { status: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
const FALLBACK_LOGS = [
|
||||
{ id: 'l1', user: 'Admin Ashwin', action: 'Updated Permissions', target: 'Verified Company', date: '2026-03-27 10:30' },
|
||||
{ id: 'l2', user: 'Admin Ashwin', action: 'Changed Status', target: 'Professional Photographer', date: '2026-03-26 14:15' },
|
||||
{ id: 'l3', user: 'System', action: 'Auto-sync Schema', target: 'Verified Company', date: '2026-03-25 09:00' },
|
||||
];
|
||||
|
||||
const FALLBACK_ASSIGNED_USERS = [
|
||||
{ id: 'u1', name: 'John Doe', email: 'john@example.com', joined: '2026-01-20' },
|
||||
{ id: 'u2', name: 'Jane Smith', email: 'jane@studios.com', joined: '2026-02-05' },
|
||||
{ id: 'u3', name: 'Alice Wong', email: 'alice@photo.me', joined: '2026-03-12' },
|
||||
];
|
||||
// TODO: Wire to real backend log/user endpoints
|
||||
const FALLBACK_LOGS: any[] = [];
|
||||
const FALLBACK_ASSIGNED_USERS: any[] = [];
|
||||
|
||||
function FormInput(props: { label: string; required?: boolean; value: string; onInput: (v: string) => void; placeholder?: string; type?: string }) {
|
||||
return (
|
||||
|
|
@ -799,13 +788,6 @@ export default function ExternalRoleManagementPage() {
|
|||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style="display:block">
|
||||
<span style="font-size:13px;font-weight:600;color:#374151">Onboarding Schema *</span>
|
||||
<select value={onboardingId()} onChange={e => setOnboardingId(e.currentTarget.value)} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;outline:none">
|
||||
<For each={ONBOARDING_SCHEMAS}>{id => <option value={id}>{id}</option>}</For>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,684 +1,335 @@
|
|||
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createResource, createSignal, Show, For, createEffect } from 'solid-js';
|
||||
import {
|
||||
BookOpen, Plus, Search, Edit2, Trash2, CheckCircle,
|
||||
Clock, User, ChevronRight, FileText, LayoutGrid, List,
|
||||
Save, X, Globe, Lock, AlertCircle
|
||||
} from 'lucide-solid';
|
||||
import { saveRuntimeConfig, getRuntimeConfig } from '~/lib/runtime/storage';
|
||||
import { RuntimeKBConfig, RuntimeKBArticle } from '~/lib/runtime/types';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type KbCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
article_count?: number;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
type KbArticle = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug?: string;
|
||||
category_id?: string;
|
||||
category?: string;
|
||||
status: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
type SeedArticle = {
|
||||
title: string;
|
||||
slug: string;
|
||||
categorySlug: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const KB_SEED_CATEGORIES: Array<{ name: string; slug: string; description: string }> = [
|
||||
{ name: 'Getting Started', slug: 'getting-started', description: 'Account setup, role selection, and first actions.' },
|
||||
{ name: 'Account & Login', slug: 'account-login', description: 'Login, password, account access, and security.' },
|
||||
{ name: 'Profile & Verification', slug: 'profile-verification', description: 'Profile setup and verification workflow help.' },
|
||||
{ name: 'Platform Basics', slug: 'platform-basics', description: 'Simple guides to understand core platform workflows.' },
|
||||
{ name: 'Jobs & Applications', slug: 'jobs-applications', description: 'Posting jobs, applying, and status tracking.' },
|
||||
{ name: 'Leads & Requests', slug: 'leads-requests', description: 'Handling incoming leads and request responses.' },
|
||||
{ name: 'TraceCoins & Billing', slug: 'tracecoins-billing', description: 'Credits, payments, invoices, and refunds.' },
|
||||
{ name: 'Privacy & Safety', slug: 'privacy-safety', description: 'Policy, reporting, trust and account safety.' },
|
||||
{ name: 'Troubleshooting', slug: 'troubleshooting', description: 'Common issues and quick resolution guides.' },
|
||||
const CATEGORIES = [
|
||||
'General', 'Account & Profiles', 'Lead System', 'Credits & Payments',
|
||||
'Verifications', 'Security', 'Policies', 'Troubleshooting'
|
||||
];
|
||||
|
||||
const KB_SEED_ARTICLES: SeedArticle[] = [
|
||||
{ title: 'Create your Nxtgauge account in 3 steps', slug: 'create-account-3-steps', categorySlug: 'getting-started', content: 'Step 1: Sign up with your email or mobile number.\n\nStep 2: Verify OTP and complete basic account details.\n\nStep 3: Go to role setup and add clear profile information.\n\nTip: Keep your name, location, and contact details accurate so approval and communication are smooth.' },
|
||||
{ title: 'Choose your role after signup', slug: 'choose-role-after-signup', categorySlug: 'getting-started', content: 'Nxtgauge uses one account with role-based journeys. You can choose your role after signup.\n\nIf you are a company, use the company flow for job posting.\nIf you are a professional, use the service profile flow.\nIf you are a job seeker, complete candidate details.\n\nYou can update your role path later when needed.' },
|
||||
{ title: 'Understand account statuses (Draft, Review, Approved)', slug: 'understand-account-statuses', categorySlug: 'getting-started', content: 'Draft: Your profile is incomplete and not ready for full visibility.\n\nReview: Your submission is under verification checks.\n\nApproved: Your profile or listing passed checks and is visible in discovery.\n\nIf your status stays in review for too long, check if any required data is missing.' },
|
||||
{ title: 'How the Nxtgauge platform works', slug: 'how-nxtgauge-platform-works', categorySlug: 'platform-basics', content: 'Nxtgauge is built on a trust-first workflow.\n\n1. User submits profile, requirement, or job.\n2. Platform runs verification checks.\n3. Approved submissions become discoverable.\n4. Users track status and responses in one flow.\n\nThis helps reduce spam and improves first-contact quality.' },
|
||||
{ title: 'One account, multiple journeys explained', slug: 'one-account-multiple-journeys', categorySlug: 'platform-basics', content: 'You do not need separate accounts for each use case.\n\nWith one account, you can move across role journeys based on your need.\nExamples:\n- Company posting jobs\n- Professional offering services\n- Job seeker applying to roles\n\nKeep each role profile complete for better matching.' },
|
||||
{ title: 'How review and approval improve trust', slug: 'review-approval-improve-trust', categorySlug: 'platform-basics', content: 'Review and approval help ensure better quality listings and profiles.\n\nWhy it matters:\n- Less fake activity\n- Better discovery relevance\n- Higher confidence before first contact\n\nMost checks complete in 24-48 hours for standard submissions.' },
|
||||
{ title: 'How to track progress without confusion', slug: 'track-progress-without-confusion', categorySlug: 'platform-basics', content: 'Use status labels and timeline views in your dashboard.\n\nBest practice:\n- Check updates daily\n- Respond quickly to requests\n- Keep your profile current\n\nWhen status changes, follow the next action shown in your panel.' },
|
||||
{ title: 'Common platform terms in simple words', slug: 'platform-terms-simple-words', categorySlug: 'platform-basics', content: 'Draft: Not complete yet.\nReview: Being checked by the platform.\nApproved: Passed checks and visible.\nRequirement: Customer request.\nLead: Potential opportunity.\n\nKnowing these terms helps you move faster through workflows.' },
|
||||
{ title: 'How to get better platform visibility', slug: 'get-better-platform-visibility', categorySlug: 'platform-basics', content: 'To improve visibility:\n- Complete profile fields fully\n- Add clear scope and category fit\n- Keep media and proof updated\n- Respond fast to valid requests\n\nProfiles with clear information usually get better conversion quality.' },
|
||||
{ title: 'How to reset your password', slug: 'reset-password', categorySlug: 'account-login', content: 'Use Forgot Password on login, verify via OTP, and set a new secure password to restore account access.' },
|
||||
{ title: 'Why your login OTP might fail', slug: 'otp-failure-reasons', categorySlug: 'account-login', content: 'OTP may fail due to expiry, wrong input, network delay, or blocked SMS routes. Request a fresh OTP and retry.' },
|
||||
{ title: 'Update email or phone on your account', slug: 'update-email-phone', categorySlug: 'account-login', content: 'Go to account settings and submit an update request. Certain changes may require re-verification for security.' },
|
||||
{ title: 'How to complete a high-conversion profile', slug: 'high-conversion-profile', categorySlug: 'profile-verification', content: 'Add role clarity, service scope, location, portfolio proof, pricing structure, and response timelines for better conversion.' },
|
||||
{ title: 'Verification checklist before submission', slug: 'verification-checklist-before-submit', categorySlug: 'profile-verification', content: 'Check identity fields, profile completeness, media proofs, and category fit before submitting for review.' },
|
||||
{ title: 'Expected verification timeline (24-48 hours)', slug: 'verification-timeline-24-48-hours', categorySlug: 'profile-verification', content: 'Most profile reviews are completed within 24-48 hours. Complex or incomplete submissions may take longer.' },
|
||||
{ title: 'Why verification was sent back for correction', slug: 'verification-sent-back-correction', categorySlug: 'profile-verification', content: 'Correction requests happen when details are incomplete, unclear, mismatched, or missing required proof.' },
|
||||
{ title: 'How companies can post jobs correctly', slug: 'companies-post-jobs-correctly', categorySlug: 'jobs-applications', content: 'Use clear job titles, responsibilities, compensation, location, and hiring timeline for better candidate fit.' },
|
||||
{ title: 'How job seekers can track application status', slug: 'track-application-status', categorySlug: 'jobs-applications', content: 'Open your applications panel to check submitted, viewed, shortlisted, or closed statuses in one place.' },
|
||||
{ title: 'How to improve job application quality', slug: 'improve-application-quality', categorySlug: 'jobs-applications', content: 'Tailor profile details to each role, keep availability updated, and include relevant proof of work where possible.' },
|
||||
{ title: 'Handling duplicate or irrelevant leads', slug: 'handle-duplicate-irrelevant-leads', categorySlug: 'leads-requests', content: 'Use request filters, quick decline actions, and profile targeting updates to reduce low-fit lead volume.' },
|
||||
{ title: 'Best response window for higher lead conversion', slug: 'best-response-window-lead-conversion', categorySlug: 'leads-requests', content: 'Respond early with clear scope and timeline. Fast, structured replies generally improve first-contact conversion.' },
|
||||
{ title: 'How to organize incoming customer requests', slug: 'organize-customer-requests', categorySlug: 'leads-requests', content: 'Tag requests by urgency, scope, and budget range to prioritize responses and avoid missed opportunities.' },
|
||||
{ title: 'What are TraceCoins and where they are used', slug: 'what-are-tracecoins', categorySlug: 'tracecoins-billing', content: 'TraceCoins are platform credits used for selected actions such as premium interactions and visibility utilities.' },
|
||||
{ title: 'How to view invoices and payment history', slug: 'view-invoices-payment-history', categorySlug: 'tracecoins-billing', content: 'Open billing settings to view purchase records, invoice references, and transaction status history.' },
|
||||
{ title: 'Refund and failed payment guidance', slug: 'refund-failed-payment-guidance', categorySlug: 'tracecoins-billing', content: 'If payment fails or is disputed, check transaction status first, then raise a support request with payment reference.' },
|
||||
{ title: 'How to report suspicious activity', slug: 'report-suspicious-activity', categorySlug: 'privacy-safety', content: 'Use report actions on profiles or listings, include evidence, and avoid direct engagement with suspicious accounts.' },
|
||||
{ title: 'Managing privacy settings for your account', slug: 'manage-privacy-settings', categorySlug: 'privacy-safety', content: 'Control profile visibility, notification preferences, and contact access from account privacy controls.' },
|
||||
{ title: 'Account safety best practices', slug: 'account-safety-best-practices', categorySlug: 'privacy-safety', content: 'Use strong passwords, rotate credentials, enable secure recovery methods, and avoid sharing verification codes.' },
|
||||
{ title: 'Fix profile image or upload issues', slug: 'fix-profile-image-upload-issues', categorySlug: 'troubleshooting', content: 'Try compressed files, supported formats, and stable network. Re-upload after clearing cached session state if needed.' },
|
||||
{ title: 'What to do if dashboard data does not refresh', slug: 'dashboard-data-not-refreshing', categorySlug: 'troubleshooting', content: 'Refresh the session, confirm role context, and retry after a short delay. Contact support if stale state persists.' },
|
||||
{ title: 'Why your listing may not appear in search', slug: 'listing-not-appearing-in-search', categorySlug: 'platform-basics', content: 'Listings may not appear when status is Draft/Review, category mapping is weak, or profile details are incomplete.\n\nCheck status first, then update role/category details and retry.' },
|
||||
{ title: 'Platform checklist before submitting anything', slug: 'platform-checklist-before-submitting', categorySlug: 'platform-basics', content: 'Before submit:\n- Verify title and category\n- Add clear scope and location\n- Add supporting details\n- Check contact correctness\n\nThis reduces correction loops and helps faster approval.' },
|
||||
{ title: 'How to use Help Center effectively', slug: 'use-help-center-effectively', categorySlug: 'platform-basics', content: 'Search by simple keywords like login, verification, leads, billing, status, and approval.\n\nOpen related articles to understand connected steps.\n\nIf still blocked, share your exact issue with screenshots when contacting support.' },
|
||||
];
|
||||
|
||||
async function loadCategories(): Promise<KbCategory[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/kb/categories`);
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : (data.categories || []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
async function loadKBArticles(): Promise<RuntimeKBConfig> {
|
||||
const config = await getRuntimeConfig<RuntimeKBConfig>('knowledge_base', 'GLOBAL_KB');
|
||||
return config?.payload || { articles: [] };
|
||||
}
|
||||
|
||||
async function loadArticles(): Promise<KbArticle[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/kb/articles`);
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : (data.articles || []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export default function KnowledgeBasePage() {
|
||||
const [kbData, { refetch }] = createResource(loadKBArticles);
|
||||
const [view, setView] = createSignal<'list' | 'editor'>('list');
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
const [selectedCategory, setSelectedCategory] = createSignal('All');
|
||||
|
||||
// Editor state
|
||||
const [editingArticle, setEditingArticle] = createSignal<Partial<RuntimeKBArticle> | null>(null);
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
|
||||
type KbPageProps = {
|
||||
initialTab?: 'categories' | 'articles' | 'create-article';
|
||||
};
|
||||
const filteredArticles = () => {
|
||||
const articles = kbData()?.articles || [];
|
||||
return articles.filter(a => {
|
||||
const matchesSearch = a.title.toLowerCase().includes(searchQuery().toLowerCase()) ||
|
||||
a.content.toLowerCase().includes(searchQuery().toLowerCase());
|
||||
const matchesCategory = selectedCategory() === 'All' || a.category === selectedCategory();
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
};
|
||||
|
||||
export default function KbPage(props: KbPageProps = {}) {
|
||||
const [tab, setTab] = createSignal<'categories' | 'articles' | 'create-article'>(props.initialTab || 'categories');
|
||||
const handleEdit = (article: RuntimeKBArticle) => {
|
||||
setEditingArticle({ ...article });
|
||||
setView('editor');
|
||||
};
|
||||
|
||||
// Categories resource
|
||||
const [categories, { refetch: refetchCategories }] = createResource(loadCategories);
|
||||
// Articles resource
|
||||
const [articles, { refetch: refetchArticles }] = createResource(loadArticles);
|
||||
const handleCreate = () => {
|
||||
setEditingArticle({
|
||||
id: `kb-${Date.now()}`,
|
||||
title: '',
|
||||
category: 'General',
|
||||
content: '',
|
||||
author: 'Admin',
|
||||
isPublished: false,
|
||||
viewCount: 0,
|
||||
lastUpdated: new Date().toISOString()
|
||||
});
|
||||
setView('editor');
|
||||
};
|
||||
|
||||
// --- Categories tab state ---
|
||||
const [showCatForm, setShowCatForm] = createSignal(false);
|
||||
const [catName, setCatName] = createSignal('');
|
||||
const [catSlug, setCatSlug] = createSignal('');
|
||||
const [catDesc, setCatDesc] = createSignal('');
|
||||
const [catSaving, setCatSaving] = createSignal(false);
|
||||
const [catError, setCatError] = createSignal('');
|
||||
const [editingCatId, setEditingCatId] = createSignal('');
|
||||
const [editCatName, setEditCatName] = createSignal('');
|
||||
const [editCatSlug, setEditCatSlug] = createSignal('');
|
||||
const [editCatSaving, setEditCatSaving] = createSignal(false);
|
||||
const [editCatError, setEditCatError] = createSignal('');
|
||||
const [deletingCatId, setDeletingCatId] = createSignal('');
|
||||
const [actionError, setActionError] = createSignal('');
|
||||
const handleSave = async () => {
|
||||
const article = editingArticle();
|
||||
if (!article || !article.title || !article.content) return;
|
||||
|
||||
// --- Articles tab state ---
|
||||
const [articleSearch, setArticleSearch] = createSignal('');
|
||||
const [deletingArticleId, setDeletingArticleId] = createSignal('');
|
||||
const [articleActionError, setArticleActionError] = createSignal('');
|
||||
const [seedingArticles, setSeedingArticles] = createSignal(false);
|
||||
const [seedMessage, setSeedMessage] = createSignal('');
|
||||
|
||||
// --- Create Article tab state ---
|
||||
const [artTitle, setArtTitle] = createSignal('');
|
||||
const [artSlug, setArtSlug] = createSignal('');
|
||||
const [artCategoryId, setArtCategoryId] = createSignal('');
|
||||
const [artContent, setArtContent] = createSignal('');
|
||||
const [artStatus, setArtStatus] = createSignal('DRAFT');
|
||||
const [artSaving, setArtSaving] = createSignal(false);
|
||||
const [artError, setArtError] = createSignal('');
|
||||
|
||||
// Filtered articles
|
||||
const filteredArticles = createMemo(() => {
|
||||
const all = articles() ?? [];
|
||||
const q = articleSearch().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter((a) => a.title?.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
// Categories actions
|
||||
const handleAddCategory = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
setCatSaving(true);
|
||||
setCatError('');
|
||||
const res = await fetch(`${API}/api/admin/kb/categories`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: catName(), slug: catSlug(), description: catDesc() }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create category');
|
||||
setCatName('');
|
||||
setCatSlug('');
|
||||
setCatDesc('');
|
||||
setShowCatForm(false);
|
||||
refetchCategories();
|
||||
} catch (err: any) {
|
||||
setCatError(err.message || 'Failed to create');
|
||||
} finally {
|
||||
setCatSaving(false);
|
||||
}
|
||||
};
|
||||
const currentArticles = kbData()?.articles || [];
|
||||
const index = currentArticles.findIndex(a => a.id === article.id);
|
||||
|
||||
const updatedArticle = {
|
||||
...article,
|
||||
lastUpdated: new Date().toISOString()
|
||||
} as RuntimeKBArticle;
|
||||
|
||||
const startEditCat = (cat: KbCategory) => {
|
||||
setEditingCatId(cat.id);
|
||||
setEditCatName(cat.name);
|
||||
setEditCatSlug(cat.slug);
|
||||
setEditCatError('');
|
||||
};
|
||||
|
||||
const cancelEditCat = () => {
|
||||
setEditingCatId('');
|
||||
setEditCatError('');
|
||||
};
|
||||
|
||||
const saveEditCat = async (id: string) => {
|
||||
try {
|
||||
setEditCatSaving(true);
|
||||
setEditCatError('');
|
||||
const res = await fetch(`${API}/api/admin/kb/categories/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: editCatName(), slug: editCatSlug() }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save');
|
||||
setEditingCatId('');
|
||||
refetchCategories();
|
||||
} catch (err: any) {
|
||||
setEditCatError(err.message || 'Failed to save');
|
||||
} finally {
|
||||
setEditCatSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCategory = async (id: string, name: string) => {
|
||||
if (!confirm(`Delete category "${name}"?`)) return;
|
||||
try {
|
||||
setDeletingCatId(id);
|
||||
setActionError('');
|
||||
const res = await fetch(`${API}/api/admin/kb/categories/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Failed to delete');
|
||||
refetchCategories();
|
||||
} catch (err: any) {
|
||||
setActionError(err.message || 'Failed to delete');
|
||||
} finally {
|
||||
setDeletingCatId('');
|
||||
}
|
||||
};
|
||||
|
||||
// Articles actions
|
||||
const deleteArticle = async (id: string, title: string) => {
|
||||
if (!confirm(`Delete article "${title}"?`)) return;
|
||||
try {
|
||||
setDeletingArticleId(id);
|
||||
setArticleActionError('');
|
||||
const res = await fetch(`${API}/api/admin/kb/articles/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Failed to delete');
|
||||
refetchArticles();
|
||||
} catch (err: any) {
|
||||
setArticleActionError(err.message || 'Failed to delete');
|
||||
} finally {
|
||||
setDeletingArticleId('');
|
||||
}
|
||||
};
|
||||
|
||||
const seedKnowledgeBaseArticles = async () => {
|
||||
try {
|
||||
setSeedingArticles(true);
|
||||
setArticleActionError('');
|
||||
setSeedMessage('');
|
||||
|
||||
const categoriesRes = await fetch(`${API}/api/admin/kb/categories`);
|
||||
if (!categoriesRes.ok) throw new Error('Failed to load categories for seeding');
|
||||
const categoriesJson = await categoriesRes.json();
|
||||
const existingCategories: KbCategory[] = Array.isArray(categoriesJson) ? categoriesJson : (categoriesJson.categories || []);
|
||||
const categoryBySlug = new Map(existingCategories.map((c) => [c.slug, c]));
|
||||
|
||||
for (const cat of KB_SEED_CATEGORIES) {
|
||||
if (categoryBySlug.has(cat.slug)) continue;
|
||||
const createRes = await fetch(`${API}/api/admin/kb/categories`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cat),
|
||||
});
|
||||
if (!createRes.ok) throw new Error(`Failed to create category: ${cat.name}`);
|
||||
let newArticles = [...currentArticles];
|
||||
if (index >= 0) {
|
||||
newArticles[index] = updatedArticle;
|
||||
} else {
|
||||
newArticles.unshift(updatedArticle);
|
||||
}
|
||||
|
||||
const freshCategoriesRes = await fetch(`${API}/api/admin/kb/categories`);
|
||||
if (!freshCategoriesRes.ok) throw new Error('Failed to refresh categories after seeding');
|
||||
const freshCategoriesJson = await freshCategoriesRes.json();
|
||||
const freshCategories: KbCategory[] = Array.isArray(freshCategoriesJson) ? freshCategoriesJson : (freshCategoriesJson.categories || []);
|
||||
const freshCategoryBySlug = new Map(freshCategories.map((c) => [c.slug, c]));
|
||||
|
||||
const articlesRes = await fetch(`${API}/api/admin/kb/articles`);
|
||||
if (!articlesRes.ok) throw new Error('Failed to load articles for duplicate checks');
|
||||
const articlesJson = await articlesRes.json();
|
||||
const existingArticles: KbArticle[] = Array.isArray(articlesJson) ? articlesJson : (articlesJson.articles || []);
|
||||
const existingSlugs = new Set(existingArticles.map((a) => (a.slug || '').trim()).filter(Boolean));
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const article of KB_SEED_ARTICLES) {
|
||||
if (existingSlugs.has(article.slug)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const category = freshCategoryBySlug.get(article.categorySlug);
|
||||
if (!category) throw new Error(`Missing category for seed article: ${article.title}`);
|
||||
|
||||
const createArticleRes = await fetch(`${API}/api/admin/kb/articles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: article.title,
|
||||
slug: article.slug,
|
||||
content: article.content,
|
||||
status: 'PUBLISHED',
|
||||
category_id: category.id,
|
||||
}),
|
||||
});
|
||||
if (!createArticleRes.ok) throw new Error(`Failed to create article: ${article.title}`);
|
||||
existingSlugs.add(article.slug);
|
||||
created += 1;
|
||||
}
|
||||
|
||||
refetchCategories();
|
||||
refetchArticles();
|
||||
setSeedMessage(`KB seeding complete: ${created} created, ${skipped} skipped (already existed).`);
|
||||
} catch (err: any) {
|
||||
setArticleActionError(err?.message || 'Failed to seed KB articles');
|
||||
await saveRuntimeConfig('knowledge_base', 'GLOBAL_KB', { articles: newArticles }, 'published');
|
||||
await refetch();
|
||||
setView('list');
|
||||
setEditingArticle(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to save article:', err);
|
||||
} finally {
|
||||
setSeedingArticles(false);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Create article
|
||||
const handleCreateArticle = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this article?')) return;
|
||||
|
||||
try {
|
||||
setArtSaving(true);
|
||||
setArtError('');
|
||||
const body: Record<string, any> = {
|
||||
title: artTitle(),
|
||||
slug: artSlug(),
|
||||
content: artContent(),
|
||||
status: artStatus(),
|
||||
};
|
||||
if (artCategoryId()) body.category_id = artCategoryId();
|
||||
const res = await fetch(`${API}/api/admin/kb/articles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
const currentArticles = kbData()?.articles || [];
|
||||
const newArticles = currentArticles.filter(a => a.id !== id);
|
||||
await saveRuntimeConfig('knowledge_base', 'GLOBAL_KB', { articles: newArticles }, 'published');
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete article:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const seedArticles = async () => {
|
||||
if (!confirm('This will seed 40 platform articles. Continue?')) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const articles: RuntimeKBArticle[] = [];
|
||||
|
||||
// Generation logic for 40 articles
|
||||
const articleTemplates = [
|
||||
{ category: 'Verifications', topics: ['Business Approval', 'Identity Verification', 'Media Verification', 'Verification Timeline', 'Common Rejection Reasons'] },
|
||||
{ category: 'Lead System', topics: ['How to Find Leads', 'Response Quality', 'Budget Matching', 'Unlocking Contacts', 'Lead Expiry'] },
|
||||
{ category: 'Credits & Payments', topics: ['What are Tracecoins', 'Purchasing Plans', 'Refund Policy', 'Tax Invoices', 'Payment Failures'] },
|
||||
{ category: 'Account & Profiles', topics: ['Portfolio Best Practices', 'Service Switching', 'Subscription Settings', 'Privacy Control', 'Profile URL Customization'] },
|
||||
{ category: 'Security', topics: ['Password Recovery', '2FA Setup', 'Scam Prevention', 'Data Privacy GDPR', 'Account Recovery'] },
|
||||
{ category: 'Troubleshooting', topics: ['App Performance', 'Notification Settings', 'Browser Compatibility', 'Image Upload Issues', 'Logout Problems'] },
|
||||
{ category: 'Policies', topics: ['Terms of Service', 'Privacy Policy', 'Cancellation Policy', 'Refund Terms', 'Platform Guidelines'] },
|
||||
{ category: 'General', topics: ['Welcome Guide', 'Mastering the Dashboard', 'Referral Program', 'Support Channels', 'App Updates'] },
|
||||
];
|
||||
|
||||
articleTemplates.forEach(t => {
|
||||
t.topics.forEach((topic, i) => {
|
||||
articles.push({
|
||||
id: `kb-seed-${t.category.toLowerCase().replace(/\s/g, '-')}-${i}`,
|
||||
title: topic,
|
||||
category: t.category,
|
||||
content: `This is a comprehensive guide about ${topic} on the Nxtgauge platform. We cover everything from initial setup to advanced strategies for success.
|
||||
|
||||
### Key Highlights:
|
||||
1. Understanding the fundamentals of ${topic}.
|
||||
2. Step-by-step implementation guide.
|
||||
3. Troubleshooting common roadblocks.
|
||||
4. Professional tips for maximizing your business results.
|
||||
|
||||
Nxtgauge is dedicated to providing the best tools for your professional growth. Check back frequently for updates on this guide.`,
|
||||
author: 'System Architect',
|
||||
isPublished: true,
|
||||
viewCount: Math.floor(Math.random() * 500) + 100,
|
||||
lastUpdated: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create article');
|
||||
setArtTitle('');
|
||||
setArtSlug('');
|
||||
setArtCategoryId('');
|
||||
setArtContent('');
|
||||
setArtStatus('DRAFT');
|
||||
setTab('articles');
|
||||
refetchArticles();
|
||||
} catch (err: any) {
|
||||
setArtError(err.message || 'Failed to create');
|
||||
|
||||
await saveRuntimeConfig('knowledge_base', 'GLOBAL_KB', { articles }, 'published');
|
||||
await refetch();
|
||||
alert('40 articles seeded successfully!');
|
||||
} catch (err) {
|
||||
console.error('Seeding failed:', err);
|
||||
} finally {
|
||||
setArtSaving(false);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<h1 class="text-xl font-semibold text-gray-900">Knowledge Base</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Manage help articles and categories</p>
|
||||
<div class="flex flex-col -mx-6 -mt-6 min-h-full bg-[#F8FAFC]">
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-[#0F172A] flex items-center gap-2">
|
||||
<BookOpen size={20} class="text-[#FF5E13]" />
|
||||
Knowledge Base Management
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Manage help articles and runtime documentation.</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div class="bg-white border-b border-gray-200 px-6 flex gap-6 overflow-x-auto">
|
||||
<button
|
||||
type="button"
|
||||
class={tab() === 'categories' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
|
||||
onClick={() => setTab('categories')}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onClick={seedArticles}
|
||||
class="px-4 py-2 text-sm font-semibold text-[#64748B] hover:bg-gray-100 rounded-lg border border-gray-200 transition-all flex items-center gap-2"
|
||||
>
|
||||
Categories
|
||||
<AlertCircle size={16} /> Seed 40 Articles
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={tab() === 'articles' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
|
||||
onClick={() => setTab('articles')}
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
class="px-4 py-2 text-sm font-semibold text-white bg-[#FF5E13] hover:bg-[#E54D00] rounded-lg shadow-sm transition-all flex items-center gap-2"
|
||||
>
|
||||
Articles
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={tab() === 'create-article' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
|
||||
onClick={() => setTab('create-article')}
|
||||
>
|
||||
Create Article
|
||||
<Plus size={16} /> New Article
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-6">
|
||||
|
||||
{/* Categories Tab */}
|
||||
<Show when={tab() === 'categories'}>
|
||||
<div class="mb-6 flex items-start justify-between gap-4" style="margin-bottom:16px">
|
||||
<div />
|
||||
<button class="btn-primary" onClick={() => setShowCatForm(!showCatForm())}>
|
||||
{showCatForm() ? 'Cancel' : 'Add Category'}
|
||||
</button>
|
||||
<Show when={view() === 'list'} fallback={
|
||||
<div class="p-6 max-w-4xl mx-auto w-full">
|
||||
<div class="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<div class="p-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
|
||||
<h2 class="font-bold text-gray-800">{editingArticle()?.id ? 'Edit' : 'Create'} Article</h2>
|
||||
<button onClick={() => setView('list')} class="text-gray-400 hover:text-gray-600"><X size={20}/></button>
|
||||
</div>
|
||||
|
||||
<Show when={actionError()}>
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:12px">{actionError()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={showCatForm()}>
|
||||
<div class="table-card" style="margin-bottom:16px;max-width:480px">
|
||||
<h2 style="margin:0 0 16px;font-size:15px;font-weight:700">New Category</h2>
|
||||
<Show when={catError()}>
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:10px">{catError()}</div>
|
||||
</Show>
|
||||
<form onSubmit={handleAddCategory} style="display:flex;flex-direction:column;gap:12px">
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={catName()}
|
||||
onInput={(e) => setCatName(e.currentTarget.value)}
|
||||
required
|
||||
placeholder="e.g. Getting Started"
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={catSlug()}
|
||||
onInput={(e) => setCatSlug(e.currentTarget.value)}
|
||||
required
|
||||
placeholder="e.g. getting-started"
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Description</label>
|
||||
<textarea
|
||||
value={catDesc()}
|
||||
onInput={(e) => setCatDesc(e.currentTarget.value)}
|
||||
rows="3"
|
||||
placeholder="Brief description..."
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;resize:vertical"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn-primary" type="submit" disabled={catSaving()}>
|
||||
{catSaving() ? 'Saving...' : 'Create Category'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingArticle()?.title || ''}
|
||||
onInput={(e) => setEditingArticle(p => ({ ...p!, title: e.currentTarget.value }))}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#FF5E13] focus:border-[#FF5E13] outline-none"
|
||||
placeholder="Article Title"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="overflow-x-auto">
|
||||
<table data-table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th>Article Count</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={categories.loading}>
|
||||
<tr><td colspan="4" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
|
||||
</Show>
|
||||
<Show when={!categories.loading && categories.error}>
|
||||
<tr><td colspan="4" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
|
||||
</Show>
|
||||
<Show when={!categories.loading && !categories.error && (categories()?.length ?? 0) === 0}>
|
||||
<tr><td colspan="4" style="text-align:center;padding:32px;color:#94a3b8">No categories found.</td></tr>
|
||||
</Show>
|
||||
<Show when={!categories.loading && !categories.error && (categories()?.length ?? 0) > 0}>
|
||||
<For each={categories()}>
|
||||
{(cat) => (
|
||||
<>
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="font-semibold text-slate-900">{cat.name}</td>
|
||||
<td class="text-slate-500" style="font-family:monospace;font-size:13px">{cat.slug}</td>
|
||||
<td class="text-slate-500">{cat.article_count ?? '—'}</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={() => startEditCat(cat)}>Edit</button>
|
||||
<button
|
||||
class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors"
|
||||
disabled={deletingCatId() === cat.id}
|
||||
onClick={() => deleteCategory(cat.id, cat.name)}
|
||||
>
|
||||
{deletingCatId() === cat.id ? '...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<Show when={editingCatId() === cat.id}>
|
||||
<tr>
|
||||
<td colspan="4" style="background:#f8fafc;padding:14px">
|
||||
<Show when={editCatError()}>
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:8px">{editCatError()}</div>
|
||||
</Show>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editCatName()}
|
||||
onInput={(e) => setEditCatName(e.currentTarget.value)}
|
||||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editCatSlug()}
|
||||
onInput={(e) => setEditCatSlug(e.currentTarget.value)}
|
||||
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px"
|
||||
/>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-primary" disabled={editCatSaving()} onClick={() => saveEditCat(cat.id)}>
|
||||
{editCatSaving() ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={cancelEditCat}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Articles Tab */}
|
||||
<Show when={tab() === 'articles'}>
|
||||
<Show when={articleActionError()}>
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:12px">{articleActionError()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={seedMessage()}>
|
||||
<div class="mb-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700" style="margin-bottom:12px">{seedMessage()}</div>
|
||||
</Show>
|
||||
|
||||
<div style="margin-bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search articles by title..."
|
||||
value={articleSearch()}
|
||||
onInput={(e) => setArticleSearch(e.currentTarget.value)}
|
||||
style="padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;min-width:280px"
|
||||
/>
|
||||
<button class="btn-primary" disabled={seedingArticles()} onClick={seedKnowledgeBaseArticles}>
|
||||
{seedingArticles() ? 'Seeding KB…' : 'Seed KB Articles'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="overflow-x-auto">
|
||||
<table data-table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Category</th>
|
||||
<th>Status</th>
|
||||
<th>Updated At</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Show when={articles.loading}>
|
||||
<tr><td colspan="5" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
|
||||
</Show>
|
||||
<Show when={!articles.loading && articles.error}>
|
||||
<tr><td colspan="5" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
|
||||
</Show>
|
||||
<Show when={!articles.loading && !articles.error && filteredArticles().length === 0}>
|
||||
<tr><td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">No articles found.</td></tr>
|
||||
</Show>
|
||||
<Show when={!articles.loading && !articles.error && filteredArticles().length > 0}>
|
||||
<For each={filteredArticles()}>
|
||||
{(article) => (
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="font-semibold text-slate-900">{article.title}</td>
|
||||
<td class="text-slate-500">{article.category || '—'}</td>
|
||||
<td>
|
||||
<span class={`inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 ${article.status === 'PUBLISHED' ? 'active' : 'draft'}`}>
|
||||
{article.status || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-slate-500">
|
||||
{article.updated_at ? new Date(article.updated_at).toLocaleString() : '—'}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/kb/articles/${article.id}/edit`}>Edit</A>
|
||||
<button
|
||||
class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors"
|
||||
disabled={deletingArticleId() === article.id}
|
||||
onClick={() => deleteArticle(article.id, article.title)}
|
||||
>
|
||||
{deletingArticleId() === article.id ? '...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Create Article Tab */}
|
||||
<Show when={tab() === 'create-article'}>
|
||||
<div class="table-card" style="max-width:640px">
|
||||
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">New Article</h2>
|
||||
<Show when={artError()}>
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:14px">{artError()}</div>
|
||||
</Show>
|
||||
<form onSubmit={handleCreateArticle} style="display:flex;flex-direction:column;gap:14px">
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={artTitle()}
|
||||
onInput={(e) => setArtTitle(e.currentTarget.value)}
|
||||
required
|
||||
placeholder="e.g. How to reset your password"
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={artSlug()}
|
||||
onInput={(e) => setArtSlug(e.currentTarget.value)}
|
||||
placeholder="e.g. how-to-reset-password"
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Category</label>
|
||||
<select
|
||||
value={artCategoryId()}
|
||||
onChange={(e) => setArtCategoryId(e.currentTarget.value)}
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Category</label>
|
||||
<select
|
||||
value={editingArticle()?.category || 'General'}
|
||||
onChange={(e) => setEditingArticle(p => ({ ...p!, category: e.currentTarget.value }))}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#FF5E13] outline-none"
|
||||
>
|
||||
<option value="">— Select category —</option>
|
||||
<Show when={!categories.loading}>
|
||||
<For each={categories() ?? []}>
|
||||
{(cat) => <option value={cat.id}>{cat.name}</option>}
|
||||
</For>
|
||||
</Show>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Content</label>
|
||||
<textarea
|
||||
value={artContent()}
|
||||
onInput={(e) => setArtContent(e.currentTarget.value)}
|
||||
required
|
||||
rows="12"
|
||||
placeholder="Write article content here..."
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;resize:vertical"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Status</label>
|
||||
<select
|
||||
value={artStatus()}
|
||||
onChange={(e) => setArtStatus(e.currentTarget.value)}
|
||||
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
|
||||
>
|
||||
<option value="DRAFT">DRAFT</option>
|
||||
<option value="PUBLISHED">PUBLISHED</option>
|
||||
<For each={CATEGORIES}>{cat => <option value={cat}>{cat}</option>}</For>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn-primary" type="submit" disabled={artSaving()}>
|
||||
{artSaving() ? 'Creating...' : 'Create Article'}
|
||||
</button>
|
||||
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Status</label>
|
||||
<label class="flex items-center gap-2 py-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingArticle()?.isPublished}
|
||||
onChange={(e) => setEditingArticle(p => ({ ...p!, isPublished: e.currentTarget.checked }))}
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700">Published</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Content (Markdown Supported)</label>
|
||||
<textarea
|
||||
rows={15}
|
||||
value={editingArticle()?.content || ''}
|
||||
onInput={(e) => setEditingArticle(p => ({ ...p!, content: e.currentTarget.value }))}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#FF5E13] outline-none font-mono text-sm leading-relaxed"
|
||||
placeholder="Article content..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 border-t border-gray-200 bg-gray-50 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
class="px-4 py-2 text-sm font-bold text-gray-600 hover:text-gray-800 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving()}
|
||||
class="px-6 py-2 text-sm font-bold text-white bg-[#0F172A] hover:bg-[#1E293B] rounded-lg shadow-sm transition-all flex items-center gap-2"
|
||||
>
|
||||
<Save size={16} />
|
||||
{saving() ? 'Saving...' : 'Save Article'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<div class="px-6 py-4 flex flex-wrap gap-4 items-center justify-between border-b border-gray-200 bg-white">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedCategory('All')}
|
||||
class={`px-3 py-1.5 rounded-lg text-sm font-bold transition-all ${selectedCategory() === 'All' ? 'bg-[#FF5E13] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<For each={CATEGORIES}>{(cat) => (
|
||||
<button
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
class={`px-3 py-1.5 rounded-lg text-sm font-bold transition-all ${selectedCategory() === cat ? 'bg-[#FF5E13] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
)}</For>
|
||||
</div>
|
||||
<div class="relative w-full max-w-md">
|
||||
<Search size={18} class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search articles..."
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#FF5E13] focus:border-[#FF5E13] outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<Show when={kbData.loading}>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<Clock size={40} class="animate-spin mb-4" />
|
||||
<p>Loading Knowledge Base...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!kbData.loading && filteredArticles().length === 0}>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<AlertCircle size={40} class="mb-4" />
|
||||
<p>No articles found. Try seeding data or adjusting filters.</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={filteredArticles()}>{(article) => (
|
||||
<div class="bg-white border border-gray-200 rounded-xl shadow-sm hover:shadow-md transition-all group overflow-hidden">
|
||||
<div class="p-5 border-b border-gray-100">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<span class="px-2 py-0.5 rounded bg-gray-100 text-[10px] font-bold text-gray-500 uppercase tracking-wider">
|
||||
{article.category}
|
||||
</span>
|
||||
<Show when={article.isPublished} fallback={<span class="text-[10px] font-bold text-amber-600 bg-amber-50 px-2 py-0.5 rounded">DRAFT</span>}>
|
||||
<span class="text-[10px] font-bold text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded flex items-center gap-1">
|
||||
<CheckCircle size={10} /> PUBLISHED
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<h3 class="font-bold text-[#0F172A] text-lg leading-tight mb-2 group-hover:text-[#FF5E13] transition-colors">
|
||||
{article.title}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 line-clamp-2 leading-relaxed">
|
||||
{article.content}
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-5 py-3 bg-gray-50 flex justify-between items-center text-xs text-gray-400">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center gap-1"><Clock size={12} /> {new Date(article.lastUpdated).toLocaleDateString()}</span>
|
||||
<span class="flex items-center gap-1"><User size={12} /> {article.author}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onClick={() => handleEdit(article)} class="text-gray-400 hover:text-[#FF5E13] transition-colors"><Edit2 size={16} /></button>
|
||||
<button onClick={() => handleDelete(article.id)} class="text-gray-400 hover:text-red-500 transition-colors"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,118 +172,33 @@ export default function VerificationManagementPage() {
|
|||
const load = async () => {
|
||||
try {
|
||||
setError('');
|
||||
const res = await fetch(`${API}/api/admin/approvals?page=1&limit=200`, {
|
||||
const res = await fetch(`${API}/api/admin/verifications?page=1&limit=200`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to load verification queue (${res.status})`);
|
||||
const data = await res.json().catch(() => ({} as any));
|
||||
const items = Array.isArray(data?.items) ? data.items : [];
|
||||
|
||||
const jobs = Array.isArray(data?.jobs) ? data.jobs : [];
|
||||
const requirements = Array.isArray(data?.requirements) ? data.requirements : [];
|
||||
const profiles = Array.isArray(data?.profiles) ? data.profiles : [];
|
||||
const portfolios = Array.isArray(data?.portfolios) ? data.portfolios : [];
|
||||
|
||||
const jobRows: VerificationRow[] = jobs.map((job: any) => ({
|
||||
id: `VR-J-${String(job.id || Math.random()).slice(0, 8)}`,
|
||||
applicantName: String(job.company_name || job.created_by_name || job.title || 'Company Applicant'),
|
||||
requestType: 'Job Approval',
|
||||
roleLabel: 'Company',
|
||||
submittedOn: String(job.created_at || ''),
|
||||
status: 'PENDING',
|
||||
priority: 'HIGH',
|
||||
userType: 'COMPANY',
|
||||
area: String(job.location || job.city || 'Chennai'),
|
||||
userId: String(job.user_id || ''),
|
||||
roleKey: 'COMPANY',
|
||||
payload: job,
|
||||
}));
|
||||
|
||||
const requirementRows: VerificationRow[] = requirements.map((req: any) => ({
|
||||
id: `VR-R-${String(req.id || Math.random()).slice(0, 8)}`,
|
||||
applicantName: String(req.created_by_name || req.customer_name || req.title || 'Service Seeker Applicant'),
|
||||
requestType: 'Service Seeker Requirement',
|
||||
roleLabel: toTitle(String(req.role_key || req.profession || req.category || 'Service Seeker')),
|
||||
submittedOn: String(req.created_at || ''),
|
||||
status: 'UNDER_REVIEW',
|
||||
priority: 'MEDIUM',
|
||||
userType: 'CUSTOMER',
|
||||
area: String(req.area || req.location || req.city || 'Chennai'),
|
||||
userId: String(req.user_id || ''),
|
||||
roleKey: String(req.role_key || req.profession || req.category || 'CUSTOMER'),
|
||||
payload: req,
|
||||
}));
|
||||
|
||||
const profileRows: VerificationRow[] = profiles.map((profile: any) => {
|
||||
const roleKey = String(profile.role_key || profile.roleKey || '').toUpperCase();
|
||||
const statusRaw = String(profile.status || profile.approval_status || '').toUpperCase();
|
||||
const mappedStatus: VerificationStatus =
|
||||
statusRaw === 'APPROVED' || statusRaw === 'VERIFIED' ? 'APPROVED'
|
||||
: statusRaw === 'REJECTED' ? 'REJECTED'
|
||||
: statusRaw === 'DOCUMENTS_REQUESTED' || statusRaw === 'RE_UPLOAD_REQUESTED' ? 'DOCUMENTS_REQUESTED'
|
||||
: statusRaw === 'REVISION_REQUESTED' ? 'REVISION_REQUESTED'
|
||||
: statusRaw === 'IN_REVIEW' || statusRaw === 'UNDER_REVIEW' ? 'UNDER_REVIEW'
|
||||
: 'PENDING';
|
||||
|
||||
const userType: VerificationRow['userType'] = roleKey === 'COMPANY'
|
||||
? 'COMPANY'
|
||||
: (roleKey === 'JOB_SEEKER' || roleKey === 'JOBSEEKER')
|
||||
? 'JOBSEEKER'
|
||||
: (roleKey === 'CUSTOMER' || roleKey === 'SERVICE_SEEKER')
|
||||
? 'CUSTOMER'
|
||||
: 'PROFESSIONAL';
|
||||
|
||||
const requestType: VerificationRow['requestType'] = userType === 'COMPANY'
|
||||
? 'Company Approval'
|
||||
: userType === 'JOBSEEKER'
|
||||
? 'Job Seeker Approval'
|
||||
: userType === 'CUSTOMER'
|
||||
? 'Service Seeker Profile Approval'
|
||||
: 'Profile Approval';
|
||||
|
||||
const mergedRows: VerificationRow[] = items.map((v: any) => {
|
||||
const payload = v.payload || {};
|
||||
const userType = (v.type === 'job_approval' ? 'COMPANY' : (v.type === 'requirement_approval' ? 'CUSTOMER' : 'PROFESSIONAL')) as VerificationRow['userType'];
|
||||
|
||||
return {
|
||||
id: `VR-P-${String(profile.id || profile.user_id || Math.random()).slice(0, 8)}`,
|
||||
applicantName: String(profile.full_name || profile.name || profile.user_name || 'Applicant'),
|
||||
requestType,
|
||||
roleLabel: toTitle(roleKey || 'Professional'),
|
||||
submittedOn: String(profile.submitted_at || profile.created_at || ''),
|
||||
status: mappedStatus,
|
||||
priority: userType === 'PROFESSIONAL' ? 'HIGH' : 'MEDIUM',
|
||||
id: v.id,
|
||||
applicantName: v.user_name || 'Applicant',
|
||||
requestType: (v.type === 'job_approval' ? 'Job Approval' : (v.type === 'requirement_approval' ? 'Service Seeker Requirement' : 'Profile Approval')) as VerificationRow['requestType'],
|
||||
roleLabel: toTitle(v.role_key || 'User'),
|
||||
submittedOn: v.created_at,
|
||||
status: v.status as VerificationStatus,
|
||||
priority: 'MEDIUM',
|
||||
userType,
|
||||
area: String(profile.area || profile.city || profile.place || 'Chennai'),
|
||||
userId: String(profile.user_id || profile.id || ''),
|
||||
roleKey,
|
||||
payload: profile,
|
||||
area: payload.city || payload.area || 'Unknown',
|
||||
userId: v.user_id,
|
||||
roleKey: v.role_key,
|
||||
payload,
|
||||
};
|
||||
});
|
||||
|
||||
const portfolioRows: VerificationRow[] = portfolios.map((portfolio: any) => {
|
||||
const statusRaw = String(portfolio.status || portfolio.approval_status || '').toUpperCase();
|
||||
const mappedStatus: VerificationStatus =
|
||||
statusRaw === 'APPROVED' || statusRaw === 'VERIFIED' ? 'APPROVED'
|
||||
: statusRaw === 'REJECTED' ? 'REJECTED'
|
||||
: statusRaw === 'DOCUMENTS_REQUESTED' || statusRaw === 'RE_UPLOAD_REQUESTED' ? 'DOCUMENTS_REQUESTED'
|
||||
: statusRaw === 'REVISION_REQUESTED' ? 'REVISION_REQUESTED'
|
||||
: statusRaw === 'IN_REVIEW' || statusRaw === 'UNDER_REVIEW' || statusRaw === 'SUBMITTED' ? 'UNDER_REVIEW'
|
||||
: 'PENDING';
|
||||
|
||||
return {
|
||||
id: `VR-F-${String(portfolio.id || portfolio.user_id || Math.random()).slice(0, 8)}`,
|
||||
applicantName: String(portfolio.full_name || portfolio.name || 'Professional'),
|
||||
requestType: 'Portfolio Approval',
|
||||
roleLabel: toTitle(String(portfolio.role_key || portfolio.roleKey || 'PROFESSIONAL')),
|
||||
submittedOn: String(portfolio.submitted_at || portfolio.created_at || ''),
|
||||
status: mappedStatus,
|
||||
priority: 'HIGH',
|
||||
userType: 'PROFESSIONAL',
|
||||
area: String(portfolio.area || portfolio.city || portfolio.place || 'Chennai'),
|
||||
userId: String(portfolio.user_id || portfolio.id || ''),
|
||||
roleKey: String(portfolio.role_key || portfolio.roleKey || 'PROFESSIONAL'),
|
||||
payload: portfolio,
|
||||
};
|
||||
});
|
||||
|
||||
const mergedRows = [...profileRows, ...portfolioRows, ...jobRows, ...requirementRows];
|
||||
setRows(mergedRows);
|
||||
} catch (e: any) {
|
||||
setRows([]);
|
||||
|
|
@ -611,25 +526,7 @@ export default function VerificationManagementPage() {
|
|||
body: isReject ? JSON.stringify({ reason: requestNote() }) : undefined,
|
||||
};
|
||||
|
||||
let endpoint = '';
|
||||
const rawId = current.id.split('-').pop() || '';
|
||||
const userId = current.userId;
|
||||
|
||||
if (current.requestType === 'Job Approval') {
|
||||
const jobId = current.payload?.id || rawId;
|
||||
endpoint = `/api/admin/approvals/jobs/${jobId}/${isApprove ? 'approve' : 'reject'}`;
|
||||
} else if (current.requestType === 'Service Seeker Requirement') {
|
||||
const reqId = current.payload?.id || rawId;
|
||||
endpoint = `/api/admin/approvals/requirements/${reqId}/${isApprove ? 'approve' : 'reject'}`;
|
||||
} else if (current.requestType === 'Company Approval') {
|
||||
endpoint = `/api/admin/approvals/profiles/company/${userId}/${isApprove ? 'approve' : 'reject'}`;
|
||||
} else if (current.requestType === 'Service Seeker Profile Approval') {
|
||||
endpoint = `/api/admin/approvals/profiles/customer/${userId}/${isApprove ? 'approve' : 'reject'}`;
|
||||
} else if (current.requestType === 'Job Seeker Approval') {
|
||||
endpoint = `/api/admin/approvals/profiles/job_seeker/${userId}/${isApprove ? 'approve' : 'reject'}`;
|
||||
} else {
|
||||
endpoint = `/api/admin/approvals/profiles/professional/${current.roleKey}/${userId}/${isApprove ? 'approve' : 'reject'}`;
|
||||
}
|
||||
const endpoint = `/api/admin/verifications/${current.id}/${isApprove ? 'approve' : 'reject'}`;
|
||||
|
||||
const res = await fetch(`${API}${endpoint}`, common);
|
||||
if (!res.ok) {
|
||||
|
|
@ -667,6 +564,7 @@ export default function VerificationManagementPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="w-full space-y-6 pb-8">
|
||||
<div style="margin-bottom:1.5rem">
|
||||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Verification Management</h1>
|
||||
|
|
@ -1085,5 +983,6 @@ export default function VerificationManagementPage() {
|
|||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue