refactor: rename base_url to frontend_base_url, implement MarkdownViewer, and apply code formatting across tenant pages

This commit is contained in:
Yashwin 2026-05-05 19:42:15 +05:30
parent 1d207d2dcb
commit fa3cf2c95f
8 changed files with 429 additions and 228 deletions

View File

@ -574,11 +574,11 @@ const ModulesTab = ({ tenantId }: ModulesTabProps): ReactElement => {
), ),
}, },
{ {
key: "base_url", key: "frontend_base_url",
label: "Base URL", label: "Frontend URL",
render: (module) => ( render: (module) => (
<span className="text-sm font-normal text-[#6b7280] font-mono truncate max-w-[200px]"> <span className="text-sm font-normal text-[#6b7280] font-mono truncate max-w-[200px]">
{module.base_url || "N/A"} {module.frontend_base_url || "N/A"}
</span> </span>
), ),
}, },

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState, type ReactElement } from "react"; import { useEffect, useMemo, useRef, useState, type ReactElement } from "react";
import { Layout } from "@/components/layout/Layout"; import { Layout } from "@/components/layout/Layout";
import { FormSelect, PrimaryButton, SecondaryButton, StatusBadge, FormSlider } from "@/components/shared"; import { FormSelect, PrimaryButton, StatusBadge, FormSlider, MarkdownViewer } from "@/components/shared";
import { aiService } from "@/services/ai-service"; import { aiService } from "@/services/ai-service";
import type { AIProviderInfo } from "@/types/ai"; import type { AIProviderInfo } from "@/types/ai";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
@ -230,9 +230,7 @@ const CompletionCreate = (): ReactElement => {
<p className="text-[10px] md:text-[11px] font-semibold text-[#6b7280] uppercase">Assistant</p> <p className="text-[10px] md:text-[11px] font-semibold text-[#6b7280] uppercase">Assistant</p>
</div> </div>
<div className="w-full max-w-[96%] sm:max-w-[92%] md:max-w-[88%] rounded-xl border-2 border-[#3B82F6] bg-white px-3 md:px-4 py-3 min-h-[160px] md:min-h-[200px]"> <div className="w-full max-w-[96%] sm:max-w-[92%] md:max-w-[88%] rounded-xl border-2 border-[#3B82F6] bg-white px-3 md:px-4 py-3 min-h-[160px] md:min-h-[200px]">
<p className="text-sm text-[#0f1724] whitespace-pre-wrap break-words"> <MarkdownViewer content={displayedResponse || responseData.content} />
{displayedResponse || responseData.content}
</p>
</div> </div>
</> </>
)} )}
@ -377,9 +375,9 @@ const CompletionCreate = (): ReactElement => {
)} )}
/> />
<div className="mt-2 mb-4 flex gap-2"> {/* <div className="mt-2 mb-4 flex gap-2">
<SecondaryButton onClick={() => void loadOptions()}>Reload Options</SecondaryButton> <SecondaryButton onClick={() => void loadOptions()}>Reload Options</SecondaryButton>
</div> </div> */}
<div className="border border-[rgba(0,0,0,0.08)] rounded-lg p-3 bg-[#f8fafc]"> <div className="border border-[rgba(0,0,0,0.08)] rounded-lg p-3 bg-[#f8fafc]">
<p className="text-xs font-semibold text-[#6b7280] uppercase mb-2">Last Request Stats</p> <p className="text-xs font-semibold text-[#6b7280] uppercase mb-2">Last Request Stats</p>

View File

@ -313,9 +313,9 @@ const CompletionHistory = (): ReactElement => {
<div className="space-y-5"> <div className="space-y-5">
<section className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden"> <section className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden">
<div className="p-4 md:p-5 border-b border-[rgba(0,0,0,0.08)]"> <div className="p-4 md:p-5 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm md:text-base font-semibold text-[#0f1724] mb-3"> {/* <h3 className="text-sm md:text-base font-semibold text-[#0f1724] mb-3">
Completion List Completion List
</h3> </h3> */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-3"> <div className="flex flex-col lg:flex-row lg:items-start justify-between gap-3">

View File

@ -69,7 +69,9 @@ const categoryColors: Record<string, string> = {
}; };
function getCategoryStyle(cat?: string) { function getCategoryStyle(cat?: string) {
return categoryColors[(cat || "").toLowerCase()] || "bg-gray-100 text-gray-600"; return (
categoryColors[(cat || "").toLowerCase()] || "bg-gray-100 text-gray-600"
);
} }
function copyToClipboard(text: string): void { function copyToClipboard(text: string): void {
@ -92,7 +94,7 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
setErr(false); setErr(false);
setExtractedHtml(null); setExtractedHtml(null);
setPreviewUrl(undefined); setPreviewUrl(undefined);
const isOffice = [ const isOffice = [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword", "application/msword",
@ -112,10 +114,13 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
return; return;
} }
} catch (extractionErr) { } catch (extractionErr) {
console.warn("Content extraction failed, falling back to blob preview", extractionErr); console.warn(
"Content extraction failed, falling back to blob preview",
extractionErr,
);
} }
} }
const url = await fileAttachmentService.getPreviewUrl(file.id); const url = await fileAttachmentService.getPreviewUrl(file.id);
setPreviewUrl(url); setPreviewUrl(url);
} catch (error) { } catch (error) {
@ -151,7 +156,11 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
<p className="text-sm">Preview not available</p> <p className="text-sm">Preview not available</p>
<p className="text-xs text-center px-4">{file.mime_type}</p> <p className="text-xs text-center px-4">{file.mime_type}</p>
<button <button
onClick={() => fileAttachmentService.download(file.id, file.original_name).catch(() => {})} onClick={() =>
fileAttachmentService
.download(file.id, file.original_name)
.catch(() => {})
}
className="mt-2 h-8 px-4 bg-white border border-[rgba(0,0,0,0.12)] rounded-lg text-xs font-semibold text-[#0e1b2a] hover:bg-gray-50 transition-colors shadow-sm" className="mt-2 h-8 px-4 bg-white border border-[rgba(0,0,0,0.12)] rounded-lg text-xs font-semibold text-[#0e1b2a] hover:bg-gray-50 transition-colors shadow-sm"
> >
Download to View Download to View
@ -203,9 +212,9 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
/> />
) : extractedHtml ? ( ) : extractedHtml ? (
<div className="flex-1 overflow-auto bg-white p-8"> <div className="flex-1 overflow-auto bg-white p-8">
<div <div
className="max-w-4xl mx-auto prose prose-sm prose-slate bg-white shadow-sm border border-gray-100 p-8 rounded-lg" className="max-w-4xl mx-auto prose prose-sm prose-slate bg-white shadow-sm border border-gray-100 p-8 rounded-lg"
dangerouslySetInnerHTML={{ __html: extractedHtml }} dangerouslySetInnerHTML={{ __html: extractedHtml }}
/> />
</div> </div>
) : ( ) : (
@ -214,7 +223,11 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
<p className="text-sm">Preview not available</p> <p className="text-sm">Preview not available</p>
<p className="text-xs text-[#c4cbd6]">{file.mime_type}</p> <p className="text-xs text-[#c4cbd6]">{file.mime_type}</p>
<button <button
onClick={() => fileAttachmentService.download(file.id, file.original_name).catch(() => {})} onClick={() =>
fileAttachmentService
.download(file.id, file.original_name)
.catch(() => {})
}
className="mt-2 h-8 px-4 bg-white border border-[rgba(0,0,0,0.12)] rounded-lg text-xs font-semibold text-[#0e1b2a] hover:bg-gray-50 transition-colors shadow-sm" className="mt-2 h-8 px-4 bg-white border border-[rgba(0,0,0,0.12)] rounded-lg text-xs font-semibold text-[#0e1b2a] hover:bg-gray-50 transition-colors shadow-sm"
> >
Download to View Download to View
@ -228,12 +241,20 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Version Row // Version Row
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
function VersionRow({ ver, onDownload }: { ver: FileAttachment; onDownload: () => void }): ReactElement { function VersionRow({
ver,
onDownload,
}: {
ver: FileAttachment;
onDownload: () => void;
}): ReactElement {
return ( return (
<tr className="border-b border-[rgba(0,0,0,0.05)] hover:bg-gray-50/50 transition-colors"> <tr className="border-b border-[rgba(0,0,0,0.05)] hover:bg-gray-50/50 transition-colors">
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[#0e1b2a]">v{ver.version}</span> <span className="text-sm font-semibold text-[#0e1b2a]">
v{ver.version}
</span>
{ver.is_current_version && ( {ver.is_current_version && (
<span className="inline-flex items-center text-[10px] font-semibold bg-emerald-100 text-emerald-700 rounded px-1.5 py-0.5"> <span className="inline-flex items-center text-[10px] font-semibold bg-emerald-100 text-emerald-700 rounded px-1.5 py-0.5">
Current Current
@ -241,7 +262,9 @@ function VersionRow({ ver, onDownload }: { ver: FileAttachment; onDownload: () =
)} )}
</div> </div>
</td> </td>
<td className="px-4 py-3 text-sm text-[#6b7280] whitespace-nowrap">{formatDate(ver.created_at)}</td> <td className="px-4 py-3 text-sm text-[#6b7280] whitespace-nowrap">
{formatDate(ver.created_at)}
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center text-[10px] font-bold text-blue-700"> <div className="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center text-[10px] font-bold text-blue-700">
@ -276,7 +299,9 @@ const FileView = (): ReactElement => {
const permissions = useSelector((state: RootState) => state.auth.permissions); const permissions = useSelector((state: RootState) => state.auth.permissions);
const isTenantAdmin = permissions.some( const isTenantAdmin = permissions.some(
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "update" || p.action === "*") (p) =>
(p.resource === "files" || p.resource === "*") &&
(p.action === "update" || p.action === "*"),
); );
// ── State ── // ── State ──
@ -317,10 +342,15 @@ const FileView = (): ReactElement => {
} }
}, [id]); }, [id]);
useEffect(() => { void loadFile(); }, [loadFile]); useEffect(() => {
void loadFile();
}, [loadFile]);
const handleDownload = () => { const handleDownload = () => {
if (file) fileAttachmentService.download(file.id, file.original_name).catch(() => {}); if (file)
fileAttachmentService
.download(file.id, file.original_name)
.catch(() => {});
}; };
const handleSaveMetadata = async () => { const handleSaveMetadata = async () => {
@ -341,10 +371,18 @@ const FileView = (): ReactElement => {
}; };
const copyChecksum = () => { const copyChecksum = () => {
if (file) { copyToClipboard(file.checksum); setCopiedChecksum(true); setTimeout(() => setCopiedChecksum(false), 1500); } if (file) {
copyToClipboard(file.checksum);
setCopiedChecksum(true);
setTimeout(() => setCopiedChecksum(false), 1500);
}
}; };
const copyPath = () => { const copyPath = () => {
if (file) { copyToClipboard(file.file_path); setCopiedPath(true); setTimeout(() => setCopiedPath(false), 1500); } if (file) {
copyToClipboard(file.file_path);
setCopiedPath(true);
setTimeout(() => setCopiedPath(false), 1500);
}
}; };
if (isLoading) { if (isLoading) {
@ -397,9 +435,13 @@ const FileView = (): ReactElement => {
<FileText className="w-4 h-4 text-red-500" /> <FileText className="w-4 h-4 text-red-500" />
</div> </div>
<div> <div>
<h1 className="text-lg font-semibold text-[#0e1b2a]">{file.original_name}</h1> <h1 className="text-lg font-semibold text-[#0e1b2a]">
{file.original_name}
</h1>
<p className="text-xs text-[#9aa6b2]"> <p className="text-xs text-[#9aa6b2]">
{file.mime_type} · {file.file_size_formatted || formatBytes(file.file_size)} · Uploaded {formatDate(file.created_at)} {file.mime_type} ·{" "}
{file.file_size_formatted || formatBytes(file.file_size)} ·
Uploaded {formatDate(file.created_at)}
</p> </p>
</div> </div>
</div> </div>
@ -435,9 +477,7 @@ const FileView = (): ReactElement => {
{/* Preview panel */} {/* Preview panel */}
<div className="flex-1 bg-white border border-[rgba(0,0,0,0.08)] rounded-xl overflow-hidden flex flex-col min-h-0"> <div className="flex-1 bg-white border border-[rgba(0,0,0,0.08)] rounded-xl overflow-hidden flex flex-col min-h-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[rgba(0,0,0,0.06)] shrink-0 bg-gray-50/30"> <div className="flex items-center justify-between px-4 py-2.5 border-b border-[rgba(0,0,0,0.06)] shrink-0 bg-gray-50/30">
<p className="text-xs font-semibold text-[#9aa6b2]"> <p className="text-xs font-semibold text-[#9aa6b2]">Preview</p>
Preview
</p>
</div> </div>
<FilePreviewPanel file={file} /> <FilePreviewPanel file={file} />
</div> </div>
@ -447,7 +487,9 @@ const FileView = (): ReactElement => {
{/* File Details */} {/* File Details */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl p-4"> <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-[#0e1b2a]">File Details</h3> <h3 className="text-sm font-semibold text-[#0e1b2a]">
File Details
</h3>
{isTenantAdmin && !editingMetadata && ( {isTenantAdmin && !editingMetadata && (
<button <button
onClick={() => { onClick={() => {
@ -481,7 +523,9 @@ const FileView = (): ReactElement => {
{/* Description */} {/* Description */}
<div className="mb-3"> <div className="mb-3">
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-1">Description</p> <p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-1">
Description
</p>
{editingMetadata ? ( {editingMetadata ? (
<textarea <textarea
value={draftDescription} value={draftDescription}
@ -492,30 +536,50 @@ const FileView = (): ReactElement => {
/> />
) : ( ) : (
<p className="text-sm text-[#475569] leading-relaxed"> <p className="text-sm text-[#475569] leading-relaxed">
{file.description || <span className="text-[#c4cbd6]">No description</span>} {file.description || (
<span className="text-[#c4cbd6]">No description</span>
)}
</p> </p>
)} )}
</div> </div>
{/* Category */} {/* Category */}
<div className="mb-3"> <div className="mb-3">
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-1">Category</p> <p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-1">
Category
</p>
{file.category ? ( {file.category ? (
<span className={cn("inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize", getCategoryStyle(file.category))}> <span
className={cn(
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
getCategoryStyle(file.category),
)}
>
{file.category} {file.category}
</span> </span>
) : <span className="text-sm text-[#c4cbd6]"></span>} ) : (
<span className="text-sm text-[#c4cbd6]"></span>
)}
</div> </div>
{/* Tags */} {/* Tags */}
<div className="mb-3"> <div className="mb-3">
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-1.5">Tags</p> <p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-1.5">
Tags
</p>
{editingMetadata ? ( {editingMetadata ? (
<div className="flex flex-wrap gap-1.5 border border-[rgba(0,0,0,0.12)] rounded-lg p-2 min-h-9 focus-within:ring-2 focus-within:ring-[#084cc8]/20"> <div className="flex flex-wrap gap-1.5 border border-[rgba(0,0,0,0.12)] rounded-lg p-2 min-h-9 focus-within:ring-2 focus-within:ring-[#084cc8]/20">
{draftTags.map((t) => ( {draftTags.map((t) => (
<span key={t} className="inline-flex items-center gap-1 bg-gray-100 text-xs font-medium rounded px-1.5 py-0.5"> <span
key={t}
className="inline-flex items-center gap-1 bg-gray-100 text-xs font-medium rounded px-1.5 py-0.5"
>
{t} {t}
<button onClick={() => setDraftTags((prev) => prev.filter((x) => x !== t))}> <button
onClick={() =>
setDraftTags((prev) => prev.filter((x) => x !== t))
}
>
<X className="w-3 h-3 text-[#9aa6b2]" /> <X className="w-3 h-3 text-[#9aa6b2]" />
</button> </button>
</span> </span>
@ -528,7 +592,8 @@ const FileView = (): ReactElement => {
if (e.key === "Enter" || e.key === ",") { if (e.key === "Enter" || e.key === ",") {
e.preventDefault(); e.preventDefault();
const t = draftTagInput.trim(); const t = draftTagInput.trim();
if (t && !draftTags.includes(t)) setDraftTags((prev) => [...prev, t]); if (t && !draftTags.includes(t))
setDraftTags((prev) => [...prev, t]);
setDraftTagInput(""); setDraftTagInput("");
} }
}} }}
@ -540,7 +605,10 @@ const FileView = (): ReactElement => {
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{(file.tags || []).length > 0 ? ( {(file.tags || []).length > 0 ? (
file.tags.map((tag) => ( file.tags.map((tag) => (
<span key={tag} className="inline-block bg-gray-100 text-[#475569] text-xs font-medium rounded px-2 py-0.5"> <span
key={tag}
className="inline-block bg-gray-100 text-[#475569] text-xs font-medium rounded px-2 py-0.5"
>
{tag} {tag}
</span> </span>
)) ))
@ -553,18 +621,28 @@ const FileView = (): ReactElement => {
{/* Properties */} {/* Properties */}
<div className="border-t border-[rgba(0,0,0,0.06)] pt-3 mb-3"> <div className="border-t border-[rgba(0,0,0,0.06)] pt-3 mb-3">
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-2">Properties</p> <p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-2">
Properties
</p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{[ {[
{ label: "Entity Type", value: file.entity_type }, { label: "Entity Type", value: file.entity_type },
{ label: "Entity ID", value: file.entity_id?.substring(0, 8) + "…" }, {
label: "Entity ID",
value: file.entity_id?.substring(0, 8) + "…",
},
{ label: "Source Module", value: file.source_module }, { label: "Source Module", value: file.source_module },
{ label: "Version", value: `v${file.version}` }, { label: "Version", value: `v${file.version}` },
{ label: "Downloads", value: String(file.download_count) }, { label: "Downloads", value: String(file.download_count) },
].map(({ label, value }) => ( ].map(({ label, value }) => (
<div key={label} className="flex justify-between items-center"> <div
key={label}
className="flex justify-between items-center"
>
<span className="text-xs text-[#9aa6b2]">{label}</span> <span className="text-xs text-[#9aa6b2]">{label}</span>
<span className="text-xs font-medium text-[#0e1b2a]">{value || "—"}</span> <span className="text-xs font-medium text-[#0e1b2a]">
{value || "—"}
</span>
</div> </div>
))} ))}
</div> </div>
@ -573,11 +651,15 @@ const FileView = (): ReactElement => {
{/* Metadata */} {/* Metadata */}
{file.metadata && Object.keys(file.metadata).length > 0 && ( {file.metadata && Object.keys(file.metadata).length > 0 && (
<div className="border-t border-[rgba(0,0,0,0.06)] pt-3 mb-3"> <div className="border-t border-[rgba(0,0,0,0.06)] pt-3 mb-3">
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-2">Metadata</p> <p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-2">
Metadata
</p>
<div className="space-y-1"> <div className="space-y-1">
{Object.entries(file.metadata).map(([k, v]) => ( {Object.entries(file.metadata).map(([k, v]) => (
<div key={k} className="flex items-center justify-between"> <div key={k} className="flex items-center justify-between">
<span className="text-xs text-[#9aa6b2] font-mono">{k}:</span> <span className="text-xs text-[#9aa6b2] font-mono">
{k}:
</span>
<span className="text-xs font-medium text-[#0e1b2a] font-mono max-w-[140px] truncate"> <span className="text-xs font-medium text-[#0e1b2a] font-mono max-w-[140px] truncate">
{typeof v === "object" ? JSON.stringify(v) : String(v)} {typeof v === "object" ? JSON.stringify(v) : String(v)}
</span> </span>
@ -590,28 +672,48 @@ const FileView = (): ReactElement => {
{/* Technical — Admin Only */} {/* Technical — Admin Only */}
{isTenantAdmin && ( {isTenantAdmin && (
<div className="border-t border-[rgba(0,0,0,0.06)] pt-3"> <div className="border-t border-[rgba(0,0,0,0.06)] pt-3">
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-2">Technical (Admin)</p> <p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-2">
Technical (Admin)
</p>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<p className="text-[10px] text-[#9aa6b2] mb-1 uppercase font-bold tracking-widest">Stored Path</p> <p className="text-[10px] text-[#9aa6b2] mb-1 uppercase font-bold tracking-widest">
Stored Path
</p>
<div className="flex items-center gap-1.5 bg-gray-50 rounded-lg px-2 py-1.5 border border-[rgba(0,0,0,0.04)]"> <div className="flex items-center gap-1.5 bg-gray-50 rounded-lg px-2 py-1.5 border border-[rgba(0,0,0,0.04)]">
<code className="text-[10px] text-[#475569] flex-1 truncate font-mono"> <code className="text-[10px] text-[#475569] flex-1 truncate font-mono">
{file.file_path} {file.file_path}
</code> </code>
<button onClick={copyPath} className="shrink-0 text-[#9aa6b2] hover:text-[#084cc8]"> <button
{copiedPath ? <Check className="w-3 h-3 text-emerald-500" /> : <Copy className="w-3 h-3" />} onClick={copyPath}
className="shrink-0 text-[#9aa6b2] hover:text-[#084cc8]"
>
{copiedPath ? (
<Check className="w-3 h-3 text-emerald-500" />
) : (
<Copy className="w-3 h-3" />
)}
</button> </button>
</div> </div>
</div> </div>
{file.checksum && ( {file.checksum && (
<div> <div>
<p className="text-[10px] text-[#9aa6b2] mb-1 uppercase font-bold tracking-widest text">SHA-256 Checksum</p> <p className="text-[10px] text-[#9aa6b2] mb-1 uppercase font-bold tracking-widest text">
SHA-256 Checksum
</p>
<div className="flex items-center gap-1.5 bg-gray-50 rounded-lg px-2 py-1.5 border border-[rgba(0,0,0,0.04)]"> <div className="flex items-center gap-1.5 bg-gray-50 rounded-lg px-2 py-1.5 border border-[rgba(0,0,0,0.04)]">
<code className="text-[10px] text-[#475569] flex-1 truncate font-mono"> <code className="text-[10px] text-[#475569] flex-1 truncate font-mono">
{file.checksum} {file.checksum}
</code> </code>
<button onClick={copyChecksum} className="shrink-0 text-[#9aa6b2] hover:text-[#084cc8]"> <button
{copiedChecksum ? <Check className="w-3 h-3 text-emerald-500" /> : <Copy className="w-3 h-3" />} onClick={copyChecksum}
className="shrink-0 text-[#9aa6b2] hover:text-[#084cc8]"
>
{copiedChecksum ? (
<Check className="w-3 h-3 text-emerald-500" />
) : (
<Copy className="w-3 h-3" />
)}
</button> </button>
</div> </div>
</div> </div>
@ -636,7 +738,10 @@ const FileView = (): ReactElement => {
<thead> <thead>
<tr className="border-b border-[rgba(0,0,0,0.06)]"> <tr className="border-b border-[rgba(0,0,0,0.06)]">
{["Version", "Date", "Uploader", "Size", "Action"].map((h) => ( {["Version", "Date", "Uploader", "Size", "Action"].map((h) => (
<th key={h} className="px-5 py-3 text-left text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide"> <th
key={h}
className="px-5 py-3 text-left text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide"
>
{h} {h}
</th> </th>
))} ))}
@ -645,7 +750,10 @@ const FileView = (): ReactElement => {
<tbody> <tbody>
{versions.length === 0 ? ( {versions.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="px-5 py-8 text-center text-sm text-[#9aa6b2]"> <td
colSpan={5}
className="px-5 py-8 text-center text-sm text-[#9aa6b2]"
>
No version history available No version history available
</td> </td>
</tr> </tr>
@ -655,7 +763,9 @@ const FileView = (): ReactElement => {
key={ver.id} key={ver.id}
ver={ver} ver={ver}
onDownload={() => onDownload={() =>
fileAttachmentService.download(ver.id, ver.original_name).catch(() => {}) fileAttachmentService
.download(ver.id, ver.original_name)
.catch(() => {})
} }
/> />
)) ))

View File

@ -9,7 +9,13 @@
* - Tenant-admin sees all admin actions; tenant-user sees limited view (conditional render) * - Tenant-admin sees all admin actions; tenant-user sees limited view (conditional render)
*/ */
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; import {
useCallback,
useEffect,
useMemo,
useState,
type ReactElement,
} from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { import {
@ -62,9 +68,15 @@ function formatBytes(bytes: number): string {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
} }
function getFileIcon(mime: string, name: string, primaryColor: string): ReactElement { function getFileIcon(
if (mime?.startsWith("image/")) return <Image className="w-4 h-4 text-emerald-500" />; mime: string,
if (mime === "application/pdf") return <FileText className="w-4 h-4 text-red-500" />; name: string,
primaryColor: string,
): ReactElement {
if (mime?.startsWith("image/"))
return <Image className="w-4 h-4 text-emerald-500" />;
if (mime === "application/pdf")
return <FileText className="w-4 h-4 text-red-500" />;
if ( if (
mime?.includes("spreadsheet") || mime?.includes("spreadsheet") ||
name?.endsWith(".csv") || name?.endsWith(".csv") ||
@ -129,7 +141,10 @@ function getAvatarColor(email: string) {
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// FilterDropdown (local inline, no shared dependency) // FilterDropdown (local inline, no shared dependency)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
interface DropOption { value: string; label: string } interface DropOption {
value: string;
label: string;
}
function FilterPill({ function FilterPill({
label, label,
@ -154,26 +169,36 @@ function FilterPill({
"inline-flex items-center gap-1.5 h-9 px-3 rounded-lg text-sm font-medium border transition-colors", "inline-flex items-center gap-1.5 h-9 px-3 rounded-lg text-sm font-medium border transition-colors",
value value
? "bg-opacity-5" ? "bg-opacity-5"
: "border-[rgba(0,0,0,0.1)] bg-white text-[#475569]" : "border-[rgba(0,0,0,0.1)] bg-white text-[#475569]",
)} )}
style={value ? { borderColor: primaryColor, backgroundColor: `${primaryColor}10`, color: primaryColor } : {}} style={
value
? {
borderColor: primaryColor,
backgroundColor: `${primaryColor}10`,
color: primaryColor,
}
: {}
}
> >
{label} {label}
{selected && <span style={{ color: primaryColor }}>: {selected.label}</span>} {selected && (
<span style={{ color: primaryColor }}>: {selected.label}</span>
)}
<ChevronDown className="w-3.5 h-3.5 opacity-60" /> <ChevronDown className="w-3.5 h-3.5 opacity-60" />
</button> </button>
{open && ( {open && (
<> <>
<div <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
className="fixed inset-0 z-10"
onClick={() => setOpen(false)}
/>
<div className="absolute top-full mt-1 left-0 z-20 bg-white border border-[rgba(0,0,0,0.1)] shadow-lg rounded-xl py-1 min-w-[160px]"> <div className="absolute top-full mt-1 left-0 z-20 bg-white border border-[rgba(0,0,0,0.1)] shadow-lg rounded-xl py-1 min-w-[160px]">
<button <button
onClick={() => { onChange(null); setOpen(false); }} onClick={() => {
onChange(null);
setOpen(false);
}}
className={cn( className={cn(
"w-full text-left px-3 py-2 text-sm hover:bg-gray-50", "w-full text-left px-3 py-2 text-sm hover:bg-gray-50",
!value ? "font-semibold text-[#0e1b2a]" : "text-[#475569]" !value ? "font-semibold text-[#0e1b2a]" : "text-[#475569]",
)} )}
> >
All All
@ -181,10 +206,15 @@ function FilterPill({
{options.map((opt) => ( {options.map((opt) => (
<button <button
key={opt.value} key={opt.value}
onClick={() => { onChange(opt.value); setOpen(false); }} onClick={() => {
onChange(opt.value);
setOpen(false);
}}
className={cn( className={cn(
"w-full text-left px-3 py-2 text-sm hover:bg-gray-50", "w-full text-left px-3 py-2 text-sm hover:bg-gray-50",
value === opt.value ? "font-semibold text-[#0e1b2a]" : "text-[#475569]" value === opt.value
? "font-semibold text-[#0e1b2a]"
: "text-[#475569]",
)} )}
> >
{opt.label} {opt.label}
@ -207,134 +237,146 @@ const FilesList = (): ReactElement => {
// Permission checks // Permission checks
const canCreate = permissions.some( const canCreate = permissions.some(
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "create" || p.action === "*") (p) =>
(p.resource === "files" || p.resource === "*") &&
(p.action === "create" || p.action === "*"),
); );
const canUpdate = permissions.some( const canUpdate = permissions.some(
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "update" || p.action === "*") (p) =>
(p.resource === "files" || p.resource === "*") &&
(p.action === "update" || p.action === "*"),
); );
const canDelete = permissions.some( const canDelete = permissions.some(
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "delete" || p.action === "*") (p) =>
(p.resource === "files" || p.resource === "*") &&
(p.action === "delete" || p.action === "*"),
); );
// Table columns // Table columns
const columns = useMemo<Column<FileAttachment>[]>(() => [ const columns = useMemo<Column<FileAttachment>[]>(
{ () => [
key: "original_name", {
label: "File Name", key: "original_name",
render: (file) => ( label: "File Name",
<button render: (file) => (
onClick={() => navigate(`/tenant/files/${file.id}`)} <button
className="flex items-center gap-2.5 transition-colors text-left group/link" onClick={() => navigate(`/tenant/files/${file.id}`)}
> className="flex items-center gap-2.5 transition-colors text-left group/link"
<div className="w-7 h-7 rounded-md bg-gray-50 border border-[rgba(0,0,0,0.06)] flex items-center justify-center shrink-0">
{getFileIcon(file.mime_type, file.original_name, primaryColor)}
</div>
<span
className="text-sm font-medium text-[#0e1b2a] truncate max-w-[200px]"
onMouseEnter={(e) => e.currentTarget.style.color = primaryColor}
onMouseLeave={(e) => e.currentTarget.style.color = '#0e1b2a'}
> >
{file.original_name} <div className="w-7 h-7 rounded-md bg-gray-50 border border-[rgba(0,0,0,0.06)] flex items-center justify-center shrink-0">
{getFileIcon(file.mime_type, file.original_name, primaryColor)}
</div>
<span
className="text-sm font-medium text-[#0e1b2a] truncate max-w-[200px]"
onMouseEnter={(e) => (e.currentTarget.style.color = primaryColor)}
onMouseLeave={(e) => (e.currentTarget.style.color = "#0e1b2a")}
>
{file.original_name}
</span>
</button>
),
},
{
key: "file_size",
label: "Size",
render: (file) => (
<span className="text-sm text-[#6b7280]">
{file.file_size_formatted || formatBytes(file.file_size)}
</span> </span>
</button> ),
), },
}, {
{ key: "category",
key: "file_size", label: "Category",
label: "Size", render: (file) =>
render: (file) => ( file.category ? (
<span className="text-sm text-[#6b7280]"> <span
{file.file_size_formatted || formatBytes(file.file_size)}
</span>
),
},
{
key: "category",
label: "Category",
render: (file) => (
file.category ? (
<span
className={cn(
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
getCategoryStyle(file.category)
)}
>
{file.category}
</span>
) : (
<span className="text-[#c4cbd6] text-sm"></span>
)
),
},
{
key: "source_module",
label: "Source Module",
render: (file) => (
file.source_module ? (
<span
className={cn(
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
getModuleStyle(file.source_module)
)}
>
{file.source_module}
</span>
) : (
<span className="text-[#c4cbd6] text-sm"></span>
)
),
},
{
key: "uploaded_by_email",
label: "Uploaded By",
render: (file) => (
file.uploaded_by_email ? (
<div className="flex items-center gap-2">
<div
className={cn( className={cn(
"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0", "inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
getAvatarColor(file.uploaded_by_email) getCategoryStyle(file.category),
)} )}
> >
{getInitials(file.uploaded_by_email)} {file.category}
</div>
<span className="text-sm text-[#0e1b2a] truncate max-w-[130px]">
{file.uploaded_by_email.split("@")[0]}
</span> </span>
</div> ) : (
) : ( <span className="text-[#c4cbd6] text-sm"></span>
<span className="text-sm text-[#9aa6b2]">Unknown</span> ),
) },
), {
}, key: "source_module",
{ label: "Source Module",
key: "created_at", render: (file) =>
label: "Upload Date", file.source_module ? (
render: (file) => ( <span
<span className="text-sm text-[#6b7280]">{formatDate(file.created_at)}</span> className={cn(
), "inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
}, getModuleStyle(file.source_module),
{ )}
key: "version", >
label: "Version", {file.source_module}
render: (file) => ( </span>
<span className="text-sm text-[#0e1b2a] font-medium">v{file.version}</span> ) : (
), <span className="text-[#c4cbd6] text-sm"></span>
}, ),
{ },
key: "actions", {
label: "Actions", key: "uploaded_by_email",
align: "right", label: "Uploaded By",
render: (file) => ( render: (file) =>
<ActionDropdown file.uploaded_by_email ? (
onView={() => navigate(`/tenant/files/${file.id}`)} <div className="flex items-center gap-2">
onDownload={() => handleDownload(file)} <div
onEdit={canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined} className={cn(
onDelete={canDelete ? () => openDeleteConfirm(file) : undefined} "w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
/> getAvatarColor(file.uploaded_by_email),
), )}
}, >
], [canUpdate, canDelete, navigate, primaryColor]); {getInitials(file.uploaded_by_email)}
</div>
<span className="text-sm text-[#0e1b2a] truncate max-w-[130px]">
{file.uploaded_by_email.split("@")[0]}
</span>
</div>
) : (
<span className="text-sm text-[#9aa6b2]">Unknown</span>
),
},
{
key: "created_at",
label: "Upload Date",
render: (file) => (
<span className="text-sm text-[#6b7280]">
{formatDate(file.created_at)}
</span>
),
},
{
key: "version",
label: "Version",
render: (file) => (
<span className="text-sm text-[#0e1b2a] font-medium">
v{file.version}
</span>
),
},
{
key: "actions",
label: "Actions",
align: "right",
render: (file) => (
<ActionDropdown
onView={() => navigate(`/tenant/files/${file.id}`)}
onDownload={() => handleDownload(file)}
onEdit={
canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined
}
onDelete={canDelete ? () => openDeleteConfirm(file) : undefined}
/>
),
},
],
[canUpdate, canDelete, navigate, primaryColor],
);
// ── State ── // ── State ──
const [files, setFiles] = useState<FileAttachment[]>([]); const [files, setFiles] = useState<FileAttachment[]>([]);
@ -355,27 +397,38 @@ const FilesList = (): ReactElement => {
const totalPages = Math.max(1, Math.ceil(total / limit)); const totalPages = Math.max(1, Math.ceil(total / limit));
// Filter option data // Filter option data
const [categories, setCategories] = useState<CategoriesFilterOptions["categories"]>([]); const [categories, setCategories] = useState<
CategoriesFilterOptions["categories"]
>([]);
// Upload modal // Upload modal
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
// Deleting // Deleting
const [fileToDelete, setFileToDelete] = useState<{ id: string; name: string } | null>(null); const [fileToDelete, setFileToDelete] = useState<{
id: string;
name: string;
} | null>(null);
const [isHardDelete, setIsHardDelete] = useState(false); const [isHardDelete, setIsHardDelete] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
// ── Load categories ── // ── Load categories ──
useEffect(() => { useEffect(() => {
fileAttachmentService.getCategoriesFilterOptions().then((res) => { fileAttachmentService
setCategories(res.data.categories); .getCategoriesFilterOptions()
}).catch(() => {}); .then((res) => {
setCategories(res.data.categories);
})
.catch(() => {});
moduleService.getMyModules().then((res) => { moduleService
if (res.success) { .getMyModules()
setModules(res.data.map(m => ({ id: m.id, name: m.name }))); .then((res) => {
} if (res.success) {
}).catch(() => {}); setModules(res.data.map((m) => ({ id: m.id, name: m.name })));
}
})
.catch(() => {});
}, []); }, []);
// ── Load files ── // ── Load files ──
@ -403,9 +456,10 @@ const FilesList = (): ReactElement => {
void loadFiles(); void loadFiles();
}, [loadFiles]); }, [loadFiles]);
const categoryOptions = useMemo<DropOption[]>(() => const categoryOptions = useMemo<DropOption[]>(
categories.map((c) => ({ value: c.category, label: c.category })), () => categories.map((c) => ({ value: c.category, label: c.category })),
[categories]); [categories],
);
// ── Actions ── // ── Actions ──
const handleDownload = (file: FileAttachment) => { const handleDownload = (file: FileAttachment) => {
@ -449,15 +503,16 @@ const FilesList = (): ReactElement => {
{getFileIcon(file.mime_type, file.original_name, primaryColor)} {getFileIcon(file.mime_type, file.original_name, primaryColor)}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<h3 <h3
className="text-sm font-medium text-[#0e1b2a] truncate cursor-pointer hover:underline" className="text-sm font-medium text-[#0e1b2a] truncate cursor-pointer hover:underline"
style={{ '--hover-color': primaryColor } as any} style={{ "--hover-color": primaryColor } as any}
onClick={() => navigate(`/tenant/files/${file.id}`)} onClick={() => navigate(`/tenant/files/${file.id}`)}
> >
{file.original_name} {file.original_name}
</h3> </h3>
<p className="text-xs text-[#6b7280]"> <p className="text-xs text-[#6b7280]">
{file.file_size_formatted || formatBytes(file.file_size)} v{file.version} {file.file_size_formatted || formatBytes(file.file_size)} v
{file.version}
</p> </p>
</div> </div>
</div> </div>
@ -465,7 +520,9 @@ const FilesList = (): ReactElement => {
<ActionDropdown <ActionDropdown
onView={() => navigate(`/tenant/files/${file.id}`)} onView={() => navigate(`/tenant/files/${file.id}`)}
onDownload={() => handleDownload(file)} onDownload={() => handleDownload(file)}
onEdit={canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined} onEdit={
canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined
}
onDelete={canDelete ? () => openDeleteConfirm(file) : undefined} onDelete={canDelete ? () => openDeleteConfirm(file) : undefined}
/> />
</div> </div>
@ -474,7 +531,7 @@ const FilesList = (): ReactElement => {
<span <span
className={cn( className={cn(
"px-2 py-0.5 rounded font-semibold capitalize", "px-2 py-0.5 rounded font-semibold capitalize",
getCategoryStyle(file.category) getCategoryStyle(file.category),
)} )}
> >
{file.category} {file.category}
@ -484,7 +541,7 @@ const FilesList = (): ReactElement => {
<span <span
className={cn( className={cn(
"px-2 py-0.5 rounded font-semibold capitalize", "px-2 py-0.5 rounded font-semibold capitalize",
getModuleStyle(file.source_module) getModuleStyle(file.source_module),
)} )}
> >
{file.source_module} {file.source_module}
@ -509,7 +566,8 @@ const FilesList = (): ReactElement => {
// ]} // ]}
pageHeader={{ pageHeader={{
title: "Files List", title: "Files List",
description: "Manage controlled documents across their entire lifecycle.", description:
"Manage controlled documents across their entire lifecycle.",
action: canCreate ? ( action: canCreate ? (
<PrimaryButton <PrimaryButton
id="upload-new-file-btn" id="upload-new-file-btn"
@ -529,7 +587,10 @@ const FilesList = (): ReactElement => {
{/* Search */} {/* Search */}
<SearchBox <SearchBox
value={search} value={search}
onChange={(v) => { setSearch(v); setCurrentPage(1); }} onChange={(v) => {
setSearch(v);
setCurrentPage(1);
}}
placeholder="Search by name, ID..." placeholder="Search by name, ID..."
/> />
@ -538,13 +599,16 @@ const FilesList = (): ReactElement => {
label="Category" label="Category"
options={categoryOptions} options={categoryOptions}
value={categoryFilter} value={categoryFilter}
onChange={(v) => { setCategoryFilter(v); setCurrentPage(1); }} onChange={(v) => {
setCategoryFilter(v);
setCurrentPage(1);
}}
/> />
{/* Source Module filter */} {/* Source Module filter */}
<FilterDropdown <FilterDropdown
label="Module" label="Module"
options={modules.map(m => ({ value: m.id, label: m.name }))} options={modules.map((m) => ({ value: m.id, label: m.name }))}
value={moduleIdFilter} value={moduleIdFilter}
onChange={(val) => { onChange={(val) => {
setModuleIdFilter(val as string | null); setModuleIdFilter(val as string | null);
@ -625,11 +689,16 @@ const FilesList = (): ReactElement => {
onChange={(e) => setIsHardDelete(e.target.checked)} onChange={(e) => setIsHardDelete(e.target.checked)}
className="w-4 h-4 text-red-600 focus:ring-red-500 border-red-300 rounded" className="w-4 h-4 text-red-600 focus:ring-red-500 border-red-300 rounded"
/> />
<label htmlFor="hard-delete-check" className="text-sm font-semibold text-red-700 cursor-pointer"> <label
htmlFor="hard-delete-check"
className="text-sm font-semibold text-red-700 cursor-pointer"
>
Permanent Delete (Hard Delete) Permanent Delete (Hard Delete)
</label> </label>
<p className="text-[10px] text-red-600/70 ml-1"> <p className="text-[10px] text-red-600/70 ml-1">
{isHardDelete ? "Files will be wiped from storage." : "Files will be moved to trash."} {isHardDelete
? "Files will be wiped from storage."
: "Files will be moved to trash."}
</p> </p>
</div> </div>
</DeleteConfirmationModal> </DeleteConfirmationModal>

View File

@ -115,11 +115,11 @@ const Modules = (): ReactElement => {
), ),
}, },
{ {
key: 'base_url', key: 'frontend_base_url',
label: 'Base URL', label: 'Frontend URL',
render: (module) => ( render: (module) => (
<span className="text-sm font-normal text-[#6b7280] font-mono truncate max-w-[200px]"> <span className="text-sm font-normal text-[#6b7280] font-mono truncate max-w-[200px]">
{module.base_url || 'N/A'} {module.frontend_base_url || 'N/A'}
</span> </span>
), ),
mobileLabel: 'URL', mobileLabel: 'URL',
@ -166,9 +166,9 @@ const Modules = (): ReactElement => {
</div> </div>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<span className="text-[#9aa6b2]">Base URL:</span> <span className="text-[#9aa6b2]">Frontend URL:</span>
<p className="text-[#0f1724] font-normal mt-1 font-mono truncate"> <p className="text-[#0f1724] font-normal mt-1 font-mono truncate">
{module.base_url || 'N/A'} {module.frontend_base_url || 'N/A'}
</p> </p>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">

View File

@ -8,6 +8,7 @@ import {
FilterDropdown, FilterDropdown,
ActionDropdown, ActionDropdown,
PrimaryButton, PrimaryButton,
DeleteConfirmationModal,
} from "@/components/shared"; } from "@/components/shared";
import { Plus, Play, Loader2, Eye, Trash2 } from "lucide-react"; import { Plus, Play, Loader2, Eye, Trash2 } from "lucide-react";
import { aiService } from "@/services/ai-service"; import { aiService } from "@/services/ai-service";
@ -27,6 +28,9 @@ export const TenantAIProviders = (): ReactElement => {
const [testingProviders, setTestingProviders] = useState<Record<string, boolean>>({}); const [testingProviders, setTestingProviders] = useState<Record<string, boolean>>({});
const [selectedConfig, setSelectedConfig] = useState<TenantAIConfig | null>(null); const [selectedConfig, setSelectedConfig] = useState<TenantAIConfig | null>(null);
const [isViewModalOpen, setIsViewModalOpen] = useState<boolean>(false); const [isViewModalOpen, setIsViewModalOpen] = useState<boolean>(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false);
const [providerToDelete, setProviderToDelete] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const fetchConfigs = async () => { const fetchConfigs = async () => {
setIsLoading(true); setIsLoading(true);
@ -77,17 +81,27 @@ export const TenantAIProviders = (): ReactElement => {
} }
}; };
const handleDeleteConfig = async (provider: string) => { const handleDeleteConfig = (provider: string) => {
if (!window.confirm(`Are you sure you want to delete the AI provider configuration for ${provider}?`)) { setProviderToDelete(provider);
return; setIsDeleteModalOpen(true);
} };
const onConfirmDelete = async () => {
if (!providerToDelete) return;
setIsDeleting(true);
try { try {
await aiService.deleteConfig(provider); await aiService.deleteConfig(providerToDelete);
showToast.success(`${provider} config removed successfully`); showToast.success(`${providerToDelete} config removed successfully`);
void fetchConfigs(); void fetchConfigs();
setIsDeleteModalOpen(false);
} catch (err: any) { } catch (err: any) {
const msg = err?.response?.data?.error?.message || "Failed to delete AI Provider configuration."; const msg =
err?.response?.data?.error?.message ||
"Failed to delete AI Provider configuration.";
showToast.error(msg); showToast.error(msg);
} finally {
setIsDeleting(false);
setProviderToDelete(null);
} }
}; };
@ -300,6 +314,16 @@ export const TenantAIProviders = (): ReactElement => {
onClose={() => setIsViewModalOpen(false)} onClose={() => setIsViewModalOpen(false)}
config={selectedConfig} config={selectedConfig}
/> />
<DeleteConfirmationModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={onConfirmDelete}
title="Delete AI Provider"
message="Are you sure you want to delete this AI provider configuration? This action cannot be undone."
itemName={providerToDelete || ""}
isLoading={isDeleting}
/>
</Layout> </Layout>
); );
}; };

View File

@ -116,7 +116,7 @@ export interface MyModule {
description: string | null; description: string | null;
version: string; version: string;
status: string; status: string;
base_url: string; frontend_base_url: string;
health_status: string | null; health_status: string | null;
assigned_at: string; assigned_at: string;
tenant_settings: Record<string, unknown> | null; tenant_settings: Record<string, unknown> | null;