feat: implement dynamic knowledge base management and seeding

This commit is contained in:
Ashwin Kumar 2026-04-06 03:33:11 +02:00
parent 9bfaa2279c
commit ac146c4d36
6 changed files with 358 additions and 888 deletions

View file

@ -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> = {

View file

@ -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[];
};

View file

@ -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: {

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
</>
);
}