E2E-Frontend-Data-Eyond / src /app /components /KnowledgeManagement.tsx
ishaq101's picture
[KM-476] [DED][FE] Knowledge (Document and DB) Flow Page
d808371
import { useState, useEffect } from "react";
import {
Upload,
Trash2,
FileText,
Check,
Loader2,
Database,
X,
ChevronLeft,
Link,
} from "lucide-react";
import { toast } from "sonner";
import {
getDocuments,
uploadDocument,
processDocument,
deleteDocument,
getDocumentTypes,
getDatabaseClientTypes,
connectDatabase,
getDatabaseClients,
deleteDatabaseClient,
ingestDatabaseClient,
type ApiDocument,
type DocumentStatus,
type DocTypeInfo,
type DbType,
type DbTypeInfo,
type DatabaseClient,
} from "../../services/api";
interface KnowledgeManagementProps {
open: boolean;
onClose: () => void;
}
type View = "main" | "db-select" | "db-credentials";
const LOGO_MAP: Record<string, string> = {
postgres: "https://cdn.simpleicons.org/postgresql/336791",
mysql: "https://cdn.simpleicons.org/mysql/4479A1",
supabase: "https://cdn.simpleicons.org/supabase/3ECF8E",
sqlserver: "https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/microsoftsqlserver/microsoftsqlserver-plain.svg",
bigquery: "https://cdn.simpleicons.org/googlebigquery/4285F4",
snowflake: "https://cdn.simpleicons.org/snowflake/29B5E8",
};
const getUserId = (): string | null => {
const stored = localStorage.getItem("chatbot_user");
if (!stored) return null;
return (JSON.parse(stored).user_id as string) ?? null;
};
export default function KnowledgeManagement({
open,
onClose,
}: KnowledgeManagementProps) {
// ── Document state ──────────────────────────────────────────────────────────
const [docTypes, setDocTypes] = useState<DocTypeInfo[]>([]);
const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [loadingDocs, setLoadingDocs] = useState(false);
const [docsError, setDocsError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [processing, setProcessing] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
// ── Navigation state ────────────────────────────────────────────────────────
const [view, setView] = useState<View>("main");
const [selectedDbType, setSelectedDbType] = useState<DbType | null>(null);
// ── DB type & client state ──────────────────────────────────────────────────
const [dbTypeInfos, setDbTypeInfos] = useState<DbTypeInfo[]>([]);
const [dbClients, setDbClients] = useState<DatabaseClient[]>([]);
const [loadingDbTypes, setLoadingDbTypes] = useState(false);
const [ingesting, setIngesting] = useState<string | null>(null);
const [deletingClient, setDeletingClient] = useState<string | null>(null);
// ── DB credentials form state ───────────────────────────────────────────────
const [connectionName, setConnectionName] = useState("");
const [dbForm, setDbForm] = useState<Record<string, string | number | boolean>>({});
const [connecting, setConnecting] = useState(false);
useEffect(() => {
if (!open) return;
const userId = getUserId();
if (!userId) return;
loadDocuments(userId);
loadDbData(userId);
getDocumentTypes()
.then((types) => setDocTypes(types.filter((t) => t.status === "active")))
.catch(() =>
setDocTypes([
{ doc_type: "pdf", max_size: 10, status: "active", message: null },
{ doc_type: "csv", max_size: 10, status: "active", message: null },
{ doc_type: "xlsx", max_size: 10, status: "active", message: null },
])
);
}, [open]);
const acceptedExtensions = docTypes.map((t) => `.${t.doc_type}`).join(",");
const supportedFormatsText = docTypes.map((t) => t.doc_type.toUpperCase()).join(", ");
const loadDbData = async (userId: string) => {
setLoadingDbTypes(true);
try {
const [types, clients] = await Promise.all([
getDatabaseClientTypes(),
getDatabaseClients(userId),
]);
setDbTypeInfos(types);
setDbClients(clients);
} catch {
// non-blocking; silently fail
} finally {
setLoadingDbTypes(false);
}
};
const handleClose = () => {
setView("main");
setSelectedDbType(null);
setConnectionName("");
setDbForm({});
onClose();
};
// ── Document handlers ───────────────────────────────────────────────────────
const loadDocuments = async (userId: string) => {
setLoadingDocs(true);
setDocsError(null);
try {
setDocuments(await getDocuments(userId));
} catch (err) {
setDocsError(
err instanceof Error ? err.message : "Failed to load documents"
);
} finally {
setLoadingDocs(false);
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const userId = getUserId();
if (!userId) return;
setUploading(true);
setUploadError(null);
for (let i = 0; i < files.length; i++) {
const file = files[i];
try {
const uploadRes = await uploadDocument(userId, file);
const newDoc: ApiDocument = {
id: uploadRes.data.id,
filename: uploadRes.data.filename,
status: "pending",
file_size: file.size,
file_type: file.name.split(".").pop() ?? "",
created_at: new Date().toISOString(),
};
setDocuments((prev) => [newDoc, ...prev]);
await processDocumentById(userId, uploadRes.data.id);
} catch (err) {
setUploadError(err instanceof Error ? err.message : "Upload failed");
}
}
setUploading(false);
e.target.value = "";
};
const processDocumentById = async (userId: string, docId: string) => {
setProcessing(docId);
setDocuments((prev) =>
prev.map((d) =>
d.id === docId ? { ...d, status: "processing" as DocumentStatus } : d
)
);
try {
await processDocument(userId, docId);
setDocuments((prev) =>
prev.map((d) =>
d.id === docId ? { ...d, status: "completed" as DocumentStatus } : d
)
);
} catch {
setDocuments((prev) =>
prev.map((d) =>
d.id === docId ? { ...d, status: "failed" as DocumentStatus } : d
)
);
} finally {
setProcessing(null);
}
};
const handleDeleteDocument = async (docId: string) => {
const userId = getUserId();
if (!userId) return;
setDeleting(docId);
try {
await deleteDocument(userId, docId);
setDocuments((prev) => prev.filter((d) => d.id !== docId));
} catch (err) {
console.error("Delete failed:", err);
} finally {
setDeleting(null);
}
};
const deleteAllDocuments = async () => {
if (!window.confirm("Are you sure you want to delete all documents?"))
return;
const userId = getUserId();
if (!userId) return;
for (const doc of documents) {
try {
await deleteDocument(userId, doc.id);
} catch {
// continue deleting others
}
}
setDocuments([]);
};
// ── DB handlers ─────────────────────────────────────────────────────────────
const handleDbConnect = async () => {
const userId = getUserId();
if (!userId || !selectedDbType || !connectionName.trim()) return;
setConnecting(true);
try {
await connectDatabase(userId, selectedDbType, connectionName.trim(), dbForm);
const clients = await getDatabaseClients(userId);
setDbClients(clients);
toast.success("Database connected successfully");
setView("main");
setConnectionName("");
setDbForm({});
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to connect to database"
);
} finally {
setConnecting(false);
}
};
const handleIngest = async (clientId: string) => {
const userId = getUserId();
if (!userId) return;
setIngesting(clientId);
try {
const res = await ingestDatabaseClient(clientId, userId);
toast.success(`Ingested ${res.chunks_ingested} chunks successfully`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Ingestion failed");
} finally {
setIngesting(null);
}
};
const handleDeleteClient = async (clientId: string) => {
const userId = getUserId();
if (!userId) return;
setDeletingClient(clientId);
try {
await deleteDatabaseClient(clientId, userId);
setDbClients((prev) => prev.filter((c) => c.id !== clientId));
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to delete connection");
} finally {
setDeletingClient(null);
}
};
// ── Helpers ─────────────────────────────────────────────────────────────────
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
return (bytes / (1024 * 1024)).toFixed(2) + " MB";
};
const formatDate = (isoString: string) => {
return new Date(isoString).toLocaleString();
};
const renderStatus = (doc: ApiDocument) => {
if (doc.status === "completed") {
return (
<div className="flex items-center gap-1.5 text-green-600">
<Check className="w-3.5 h-3.5" />
<span className="text-xs font-medium">Processed</span>
</div>
);
}
if (doc.status === "processing" || processing === doc.id) {
return (
<div className="flex items-center gap-1.5 text-blue-600">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span className="text-xs">Processing...</span>
</div>
);
}
return (
<button
onClick={() => {
const userId = getUserId();
if (userId) processDocumentById(userId, doc.id);
}}
disabled={processing === doc.id}
className="flex items-center gap-1.5 bg-gradient-to-r from-[#00C853] to-[#00A843] text-white px-3 py-1.5 rounded-lg hover:from-[#00A843] hover:to-[#00962B] transition disabled:opacity-50 disabled:cursor-not-allowed text-xs"
>
<Database className="w-3.5 h-3.5" />
{doc.status === "failed" ? "Retry Process" : "Process to Knowledge"}
</button>
);
};
if (!open) return null;
// ── Header title & back button logic ────────────────────────────────────────
const selectedDbInfo = dbTypeInfos.find((d) => d.db_type === selectedDbType);
const headerTitle =
view === "db-select"
? "Connect Database"
: view === "db-credentials"
? `Connect to ${selectedDbInfo?.display_name ?? selectedDbType}`
: "Knowledge Base";
const headerBack =
view === "db-select"
? () => setView("main")
: view === "db-credentials"
? () => setView("db-select")
: null;
// ── Render ───────────────────────────────────────────────────────────────────
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
<div className="flex items-center gap-3">
{headerBack ? (
<button
onClick={headerBack}
className="p-1.5 -ml-1 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition"
aria-label="Back"
>
<ChevronLeft className="w-4 h-4" />
</button>
) : (
<div className="w-7 h-7 rounded-lg bg-orange-100 flex items-center justify-center">
<Database className="w-4 h-4 text-[#FF8F00]" />
</div>
)}
<h2 className="text-sm font-semibold text-slate-900">{headerTitle}</h2>
</div>
<button
onClick={handleClose}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{/* ── VIEW: main ── */}
{view === "main" && (
<div className="space-y-4">
{/* Upload zone */}
<label
htmlFor="file-upload"
className="group flex flex-col items-center gap-2.5 border-2 border-dashed border-slate-200 rounded-xl p-7 cursor-pointer hover:border-[#FF8F00] hover:bg-orange-50/30 transition-colors"
>
<div className="w-10 h-10 rounded-xl bg-slate-100 group-hover:bg-orange-100 flex items-center justify-center transition-colors">
{uploading ? (
<Loader2 className="w-5 h-5 text-[#FF8F00] animate-spin" />
) : (
<Upload className="w-5 h-5 text-slate-400 group-hover:text-[#FF8F00] transition-colors" />
)}
</div>
<div className="text-center">
<p className="text-sm font-medium text-slate-700">
{uploading ? "Uploading…" : (
<>Drop files, or <span className="text-[#FF8F00]">browse</span></>
)}
</p>
<p className="text-xs text-slate-400 mt-0.5">{supportedFormatsText}</p>
</div>
<input
id="file-upload"
type="file"
accept={acceptedExtensions}
multiple
onChange={handleFileUpload}
className="hidden"
disabled={uploading}
/>
</label>
{uploadError && (
<p className="text-xs text-red-500 bg-red-50 border border-red-100 px-3 py-2 rounded-lg">
{uploadError}
</p>
)}
{/* Connect DB row */}
<button
onClick={() => setView("db-select")}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-slate-200 hover:border-slate-300 hover:bg-slate-50 transition text-left group"
>
<div className="w-8 h-8 rounded-lg bg-slate-100 group-hover:bg-slate-200 flex items-center justify-center flex-shrink-0 transition-colors">
<Link className="w-4 h-4 text-slate-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-700">Connect a Database</p>
<p className="text-xs text-slate-400">PostgreSQL and more</p>
</div>
<ChevronLeft className="w-4 h-4 text-slate-300 rotate-180 flex-shrink-0" />
</button>
{/* Database connections list */}
{dbClients.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
Databases Β· {dbClients.length}
</span>
</div>
<div className="space-y-1">
{dbClients.map((client) => (
<div
key={client.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-slate-50 transition group"
>
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center flex-shrink-0">
<img
src={LOGO_MAP[client.db_type] ?? ""}
alt={client.db_type}
className="w-5 h-5 object-contain"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">{client.name}</p>
<p className="text-xs text-slate-400 capitalize">{client.db_type} Β· {client.status}</p>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
onClick={() => handleIngest(client.id)}
disabled={ingesting === client.id || client.status === "inactive"}
title="Ingest schema to knowledge base"
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-slate-100 hover:bg-orange-100 hover:text-[#FF8F00] text-slate-500 transition disabled:opacity-40 disabled:cursor-not-allowed"
>
{ingesting === client.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Database className="w-3 h-3" />
)}
{ingesting === client.id ? "Ingesting…" : "Ingest"}
</button>
<button
onClick={() => handleDeleteClient(client.id)}
disabled={deletingClient === client.id}
className="opacity-0 group-hover:opacity-100 text-slate-300 hover:text-red-500 transition disabled:opacity-30"
title="Delete connection"
>
{deletingClient === client.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Documents list */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
Documents Β· {documents.length}
</span>
{documents.length > 0 && (
<button
onClick={deleteAllDocuments}
className="text-xs text-slate-400 hover:text-red-500 flex items-center gap-1 transition"
>
<Trash2 className="w-3 h-3" />
Clear all
</button>
)}
</div>
{loadingDocs ? (
<div className="flex justify-center py-10">
<Loader2 className="w-5 h-5 animate-spin text-slate-300" />
</div>
) : docsError ? (
<p className="text-center text-xs text-red-500 py-6">{docsError}</p>
) : documents.length === 0 ? (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-2xl bg-slate-100 flex items-center justify-center mx-auto mb-3">
<FileText className="w-5 h-5 text-slate-300" />
</div>
<p className="text-sm text-slate-400">No documents yet</p>
<p className="text-xs text-slate-300 mt-0.5">Upload files to get started</p>
</div>
) : (
<div className="space-y-1">
{documents.map((doc) => (
<div
key={doc.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-slate-50 transition group"
>
<div className="w-8 h-8 rounded-lg bg-red-50 flex items-center justify-center flex-shrink-0">
<FileText className="w-4 h-4 text-red-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate" title={doc.filename}>
{doc.filename}
</p>
<p className="text-xs text-slate-400">
{formatFileSize(doc.file_size)} Β· {formatDate(doc.created_at)}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{renderStatus(doc)}
<button
onClick={() => handleDeleteDocument(doc.id)}
disabled={deleting === doc.id}
className="opacity-0 group-hover:opacity-100 text-slate-300 hover:text-red-500 transition disabled:opacity-30"
title="Delete"
>
{deleting === doc.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* ── VIEW: db-select ── */}
{view === "db-select" && (
<div className="space-y-4">
<p className="text-sm text-slate-400">
Choose a database to connect to your knowledge base.
</p>
{loadingDbTypes ? (
<div className="flex justify-center py-10">
<Loader2 className="w-5 h-5 animate-spin text-slate-300" />
</div>
) : (
<div className="grid grid-cols-3 gap-2">
{dbTypeInfos.map((info) => {
const active = info.status === "active";
return (
<button
key={info.db_type}
disabled={!active}
onClick={() => {
if (!active) return;
setSelectedDbType(info.db_type);
const defaults = Object.fromEntries(
info.fields.map((f) => [
f.name,
f.default != null
? f.default
: f.type === "integer" ? 0 : f.type === "boolean" ? false : "",
])
);
setDbForm(defaults);
setView("db-credentials");
}}
className={[
"relative flex flex-col items-center gap-2 p-4 rounded-xl border transition text-xs font-medium",
active
? "border-slate-200 hover:border-[#FF8F00] hover:bg-orange-50/50 text-slate-700 cursor-pointer"
: "border-slate-100 bg-slate-50/60 text-slate-400 cursor-not-allowed",
].join(" ")}
>
<img
src={LOGO_MAP[info.logo] ?? ""}
alt={info.display_name}
className={`w-8 h-8 object-contain ${!active ? "opacity-30 grayscale" : ""}`}
/>
<span>{info.display_name}</span>
{!active && (
<span className="absolute top-1.5 right-1.5 text-[9px] bg-slate-200 text-slate-400 px-1.5 py-0.5 rounded-full">
Soon
</span>
)}
</button>
);
})}
</div>
)}
</div>
)}
{/* ── VIEW: db-credentials ── */}
{view === "db-credentials" && (
<div className="space-y-4">
{/* Selected DB chip */}
{selectedDbInfo && (
<div className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl bg-slate-50 border border-slate-100">
<img src={LOGO_MAP[selectedDbInfo.logo] ?? ""} alt={selectedDbInfo.display_name} className="w-5 h-5 object-contain" />
<span className="text-sm font-medium text-slate-700">{selectedDbInfo.display_name}</span>
</div>
)}
<div className="grid grid-cols-2 gap-3">
{/* Connection Name */}
<div className="col-span-2">
<label className="block text-xs font-medium text-slate-500 mb-1">
Connection Name <span className="text-red-400">*</span>
</label>
<input
type="text"
placeholder="Production DB"
value={connectionName}
onChange={(e) => setConnectionName(e.target.value)}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#FF8F00]/25 focus:border-[#FF8F00] placeholder:text-slate-300 transition"
/>
</div>
{/* Dynamic fields from API */}
{selectedDbInfo?.fields.map((field) => (
<div
key={field.name}
className={field.name === "host" || field.name === "service_account_json" ? "col-span-2" : ""}
>
<label className="block text-xs font-medium text-slate-500 mb-1 capitalize">
{field.name.replace(/_/g, " ")}
{field.required && <span className="text-red-400 ml-0.5">*</span>}
</label>
{field.type === "select" ? (
<select
value={String(dbForm[field.name] ?? field.default ?? "")}
onChange={(e) => setDbForm((f) => ({ ...f, [field.name]: e.target.value }))}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#FF8F00]/25 focus:border-[#FF8F00] transition"
>
{field.options?.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : field.type === "boolean" ? (
<div className="flex items-center gap-2 pt-1">
<input
type="checkbox"
id={`field-${field.name}`}
checked={Boolean(dbForm[field.name] ?? field.default ?? false)}
onChange={(e) => setDbForm((f) => ({ ...f, [field.name]: e.target.checked }))}
className="w-4 h-4 accent-[#FF8F00]"
/>
<label htmlFor={`field-${field.name}`} className="text-xs text-slate-500">
{field.description}
</label>
</div>
) : field.name === "service_account_json" ? (
<textarea
rows={4}
placeholder={field.description}
value={String(dbForm[field.name] ?? "")}
onChange={(e) => setDbForm((f) => ({ ...f, [field.name]: e.target.value }))}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-xs bg-white focus:outline-none focus:ring-2 focus:ring-[#FF8F00]/25 focus:border-[#FF8F00] placeholder:text-slate-300 transition font-mono"
/>
) : (
<input
type={field.sensitive ? "password" : field.type === "integer" ? "number" : "text"}
placeholder={field.default != null ? String(field.default) : field.description}
value={String(dbForm[field.name] ?? "")}
onChange={(e) =>
setDbForm((f) => ({
...f,
[field.name]: field.type === "integer"
? (parseInt(e.target.value, 10) || 0)
: e.target.value,
}))
}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#FF8F00]/25 focus:border-[#FF8F00] placeholder:text-slate-300 transition"
/>
)}
</div>
))}
</div>
<button
onClick={handleDbConnect}
disabled={
connecting ||
!connectionName.trim() ||
(selectedDbInfo?.fields.filter((f) => f.required).some((f) => !dbForm[f.name]) ?? false)
}
className="w-full flex items-center justify-center gap-2 bg-[#FF8F00] hover:bg-[#FF6F00] active:bg-[#E65100] text-white py-2.5 rounded-xl text-sm font-medium transition disabled:opacity-40 disabled:cursor-not-allowed"
>
{connecting ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Connecting…</>
) : (
"Connect"
)}
</button>
</div>
)}
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-slate-100">
<p className="text-[10px] text-slate-300 text-center">
{view === "main"
? `Supported formats: ${supportedFormatsText}`
: view === "db-select"
? "More integrations coming soon"
: "Credentials are encrypted at rest"}
</p>
</div>
</div>
</div>
);
}