refactor: rename base_url to frontend_base_url, implement MarkdownViewer, and apply code formatting across tenant pages
This commit is contained in:
parent
1d207d2dcb
commit
fa3cf2c95f
@ -574,11 +574,11 @@ const ModulesTab = ({ tenantId }: ModulesTabProps): ReactElement => {
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "base_url",
|
||||
label: "Base URL",
|
||||
key: "frontend_base_url",
|
||||
label: "Frontend URL",
|
||||
render: (module) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactElement } from "react";
|
||||
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 type { AIProviderInfo } from "@/types/ai";
|
||||
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>
|
||||
</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]">
|
||||
<p className="text-sm text-[#0f1724] whitespace-pre-wrap break-words">
|
||||
{displayedResponse || responseData.content}
|
||||
</p>
|
||||
<MarkdownViewer content={displayedResponse || responseData.content} />
|
||||
</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>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<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>
|
||||
|
||||
@ -313,9 +313,9 @@ const CompletionHistory = (): ReactElement => {
|
||||
<div className="space-y-5">
|
||||
<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)]">
|
||||
<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
|
||||
</h3>
|
||||
</h3> */}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-3">
|
||||
|
||||
@ -69,7 +69,9 @@ const categoryColors: Record<string, 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 {
|
||||
@ -112,7 +114,10 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
|
||||
return;
|
||||
}
|
||||
} catch (extractionErr) {
|
||||
console.warn("Content extraction failed, falling back to blob preview", extractionErr);
|
||||
console.warn(
|
||||
"Content extraction failed, falling back to blob preview",
|
||||
extractionErr,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,7 +156,11 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
|
||||
<p className="text-sm">Preview not available</p>
|
||||
<p className="text-xs text-center px-4">{file.mime_type}</p>
|
||||
<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"
|
||||
>
|
||||
Download to View
|
||||
@ -214,7 +223,11 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
|
||||
<p className="text-sm">Preview not available</p>
|
||||
<p className="text-xs text-[#c4cbd6]">{file.mime_type}</p>
|
||||
<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"
|
||||
>
|
||||
Download to View
|
||||
@ -228,12 +241,20 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Version Row
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function VersionRow({ ver, onDownload }: { ver: FileAttachment; onDownload: () => void }): ReactElement {
|
||||
function VersionRow({
|
||||
ver,
|
||||
onDownload,
|
||||
}: {
|
||||
ver: FileAttachment;
|
||||
onDownload: () => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<tr className="border-b border-[rgba(0,0,0,0.05)] hover:bg-gray-50/50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<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 && (
|
||||
<span className="inline-flex items-center text-[10px] font-semibold bg-emerald-100 text-emerald-700 rounded px-1.5 py-0.5">
|
||||
Current
|
||||
@ -241,7 +262,9 @@ function VersionRow({ ver, onDownload }: { ver: FileAttachment; onDownload: () =
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
@ -276,7 +299,9 @@ const FileView = (): ReactElement => {
|
||||
const permissions = useSelector((state: RootState) => state.auth.permissions);
|
||||
|
||||
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 ──
|
||||
@ -317,10 +342,15 @@ const FileView = (): ReactElement => {
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { void loadFile(); }, [loadFile]);
|
||||
useEffect(() => {
|
||||
void loadFile();
|
||||
}, [loadFile]);
|
||||
|
||||
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 () => {
|
||||
@ -341,10 +371,18 @@ const FileView = (): ReactElement => {
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
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) {
|
||||
@ -397,9 +435,13 @@ const FileView = (): ReactElement => {
|
||||
<FileText className="w-4 h-4 text-red-500" />
|
||||
</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]">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
@ -435,9 +477,7 @@ const FileView = (): ReactElement => {
|
||||
{/* 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 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]">
|
||||
Preview
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-[#9aa6b2]">Preview</p>
|
||||
</div>
|
||||
<FilePreviewPanel file={file} />
|
||||
</div>
|
||||
@ -447,7 +487,9 @@ const FileView = (): ReactElement => {
|
||||
{/* File Details */}
|
||||
<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">
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">File Details</h3>
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">
|
||||
File Details
|
||||
</h3>
|
||||
{isTenantAdmin && !editingMetadata && (
|
||||
<button
|
||||
onClick={() => {
|
||||
@ -481,7 +523,9 @@ const FileView = (): ReactElement => {
|
||||
|
||||
{/* Description */}
|
||||
<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 ? (
|
||||
<textarea
|
||||
value={draftDescription}
|
||||
@ -492,30 +536,50 @@ const FileView = (): ReactElement => {
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<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 ? (
|
||||
<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}
|
||||
</span>
|
||||
) : <span className="text-sm text-[#c4cbd6]">—</span>}
|
||||
) : (
|
||||
<span className="text-sm text-[#c4cbd6]">—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<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 ? (
|
||||
<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) => (
|
||||
<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}
|
||||
<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]" />
|
||||
</button>
|
||||
</span>
|
||||
@ -528,7 +592,8 @@ const FileView = (): ReactElement => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
const t = draftTagInput.trim();
|
||||
if (t && !draftTags.includes(t)) setDraftTags((prev) => [...prev, t]);
|
||||
if (t && !draftTags.includes(t))
|
||||
setDraftTags((prev) => [...prev, t]);
|
||||
setDraftTagInput("");
|
||||
}
|
||||
}}
|
||||
@ -540,7 +605,10 @@ const FileView = (): ReactElement => {
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(file.tags || []).length > 0 ? (
|
||||
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}
|
||||
</span>
|
||||
))
|
||||
@ -553,18 +621,28 @@ const FileView = (): ReactElement => {
|
||||
|
||||
{/* Properties */}
|
||||
<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">
|
||||
{[
|
||||
{ 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: "Version", value: `v${file.version}` },
|
||||
{ label: "Downloads", value: String(file.download_count) },
|
||||
].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 font-medium text-[#0e1b2a]">{value || "—"}</span>
|
||||
<span className="text-xs font-medium text-[#0e1b2a]">
|
||||
{value || "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -573,11 +651,15 @@ const FileView = (): ReactElement => {
|
||||
{/* Metadata */}
|
||||
{file.metadata && Object.keys(file.metadata).length > 0 && (
|
||||
<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">
|
||||
{Object.entries(file.metadata).map(([k, v]) => (
|
||||
<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">
|
||||
{typeof v === "object" ? JSON.stringify(v) : String(v)}
|
||||
</span>
|
||||
@ -590,28 +672,48 @@ const FileView = (): ReactElement => {
|
||||
{/* Technical — Admin Only */}
|
||||
{isTenantAdmin && (
|
||||
<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>
|
||||
<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)]">
|
||||
<code className="text-[10px] text-[#475569] flex-1 truncate font-mono">
|
||||
{file.file_path}
|
||||
</code>
|
||||
<button 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
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
{file.checksum && (
|
||||
<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)]">
|
||||
<code className="text-[10px] text-[#475569] flex-1 truncate font-mono">
|
||||
{file.checksum}
|
||||
</code>
|
||||
<button 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
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@ -636,7 +738,10 @@ const FileView = (): ReactElement => {
|
||||
<thead>
|
||||
<tr className="border-b border-[rgba(0,0,0,0.06)]">
|
||||
{["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}
|
||||
</th>
|
||||
))}
|
||||
@ -645,7 +750,10 @@ const FileView = (): ReactElement => {
|
||||
<tbody>
|
||||
{versions.length === 0 ? (
|
||||
<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
|
||||
</td>
|
||||
</tr>
|
||||
@ -655,7 +763,9 @@ const FileView = (): ReactElement => {
|
||||
key={ver.id}
|
||||
ver={ver}
|
||||
onDownload={() =>
|
||||
fileAttachmentService.download(ver.id, ver.original_name).catch(() => {})
|
||||
fileAttachmentService
|
||||
.download(ver.id, ver.original_name)
|
||||
.catch(() => {})
|
||||
}
|
||||
/>
|
||||
))
|
||||
|
||||
@ -9,7 +9,13 @@
|
||||
* - 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 { useSelector } from "react-redux";
|
||||
import {
|
||||
@ -62,9 +68,15 @@ function formatBytes(bytes: number): string {
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function getFileIcon(mime: string, 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" />;
|
||||
function getFileIcon(
|
||||
mime: string,
|
||||
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 (
|
||||
mime?.includes("spreadsheet") ||
|
||||
name?.endsWith(".csv") ||
|
||||
@ -129,7 +141,10 @@ function getAvatarColor(email: string) {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FilterDropdown (local inline, no shared dependency)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
interface DropOption { value: string; label: string }
|
||||
interface DropOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function FilterPill({
|
||||
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",
|
||||
value
|
||||
? "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}
|
||||
{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" />
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<div 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]">
|
||||
<button
|
||||
onClick={() => { onChange(null); setOpen(false); }}
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"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
|
||||
@ -181,10 +206,15 @@ function FilterPill({
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { onChange(opt.value); setOpen(false); }}
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"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}
|
||||
@ -207,134 +237,146 @@ const FilesList = (): ReactElement => {
|
||||
|
||||
// Permission checks
|
||||
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(
|
||||
(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(
|
||||
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "delete" || p.action === "*")
|
||||
(p) =>
|
||||
(p.resource === "files" || p.resource === "*") &&
|
||||
(p.action === "delete" || p.action === "*"),
|
||||
);
|
||||
|
||||
// Table columns
|
||||
const columns = useMemo<Column<FileAttachment>[]>(() => [
|
||||
{
|
||||
key: "original_name",
|
||||
label: "File Name",
|
||||
render: (file) => (
|
||||
<button
|
||||
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'}
|
||||
const columns = useMemo<Column<FileAttachment>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "original_name",
|
||||
label: "File Name",
|
||||
render: (file) => (
|
||||
<button
|
||||
onClick={() => navigate(`/tenant/files/${file.id}`)}
|
||||
className="flex items-center gap-2.5 transition-colors text-left group/link"
|
||||
>
|
||||
{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>
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "file_size",
|
||||
label: "Size",
|
||||
render: (file) => (
|
||||
<span className="text-sm text-[#6b7280]">
|
||||
{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
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "category",
|
||||
label: "Category",
|
||||
render: (file) =>
|
||||
file.category ? (
|
||||
<span
|
||||
className={cn(
|
||||
"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
|
||||
getAvatarColor(file.uploaded_by_email)
|
||||
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
|
||||
getCategoryStyle(file.category),
|
||||
)}
|
||||
>
|
||||
{getInitials(file.uploaded_by_email)}
|
||||
</div>
|
||||
<span className="text-sm text-[#0e1b2a] truncate max-w-[130px]">
|
||||
{file.uploaded_by_email.split("@")[0]}
|
||||
{file.category}
|
||||
</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]);
|
||||
) : (
|
||||
<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(
|
||||
"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
|
||||
getAvatarColor(file.uploaded_by_email),
|
||||
)}
|
||||
>
|
||||
{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 ──
|
||||
const [files, setFiles] = useState<FileAttachment[]>([]);
|
||||
@ -355,27 +397,38 @@ const FilesList = (): ReactElement => {
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
// Filter option data
|
||||
const [categories, setCategories] = useState<CategoriesFilterOptions["categories"]>([]);
|
||||
const [categories, setCategories] = useState<
|
||||
CategoriesFilterOptions["categories"]
|
||||
>([]);
|
||||
|
||||
// Upload modal
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
// 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 [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// ── Load categories ──
|
||||
useEffect(() => {
|
||||
fileAttachmentService.getCategoriesFilterOptions().then((res) => {
|
||||
setCategories(res.data.categories);
|
||||
}).catch(() => {});
|
||||
fileAttachmentService
|
||||
.getCategoriesFilterOptions()
|
||||
.then((res) => {
|
||||
setCategories(res.data.categories);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
moduleService.getMyModules().then((res) => {
|
||||
if (res.success) {
|
||||
setModules(res.data.map(m => ({ id: m.id, name: m.name })));
|
||||
}
|
||||
}).catch(() => {});
|
||||
moduleService
|
||||
.getMyModules()
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
setModules(res.data.map((m) => ({ id: m.id, name: m.name })));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ── Load files ──
|
||||
@ -403,9 +456,10 @@ const FilesList = (): ReactElement => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const categoryOptions = useMemo<DropOption[]>(() =>
|
||||
categories.map((c) => ({ value: c.category, label: c.category })),
|
||||
[categories]);
|
||||
const categoryOptions = useMemo<DropOption[]>(
|
||||
() => categories.map((c) => ({ value: c.category, label: c.category })),
|
||||
[categories],
|
||||
);
|
||||
|
||||
// ── Actions ──
|
||||
const handleDownload = (file: FileAttachment) => {
|
||||
@ -451,13 +505,14 @@ const FilesList = (): ReactElement => {
|
||||
<div className="flex flex-col">
|
||||
<h3
|
||||
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}`)}
|
||||
>
|
||||
{file.original_name}
|
||||
</h3>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -465,7 +520,9 @@ const FilesList = (): ReactElement => {
|
||||
<ActionDropdown
|
||||
onView={() => navigate(`/tenant/files/${file.id}`)}
|
||||
onDownload={() => handleDownload(file)}
|
||||
onEdit={canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined}
|
||||
onEdit={
|
||||
canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined
|
||||
}
|
||||
onDelete={canDelete ? () => openDeleteConfirm(file) : undefined}
|
||||
/>
|
||||
</div>
|
||||
@ -474,7 +531,7 @@ const FilesList = (): ReactElement => {
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded font-semibold capitalize",
|
||||
getCategoryStyle(file.category)
|
||||
getCategoryStyle(file.category),
|
||||
)}
|
||||
>
|
||||
{file.category}
|
||||
@ -484,7 +541,7 @@ const FilesList = (): ReactElement => {
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded font-semibold capitalize",
|
||||
getModuleStyle(file.source_module)
|
||||
getModuleStyle(file.source_module),
|
||||
)}
|
||||
>
|
||||
{file.source_module}
|
||||
@ -509,7 +566,8 @@ const FilesList = (): ReactElement => {
|
||||
// ]}
|
||||
pageHeader={{
|
||||
title: "Files List",
|
||||
description: "Manage controlled documents across their entire lifecycle.",
|
||||
description:
|
||||
"Manage controlled documents across their entire lifecycle.",
|
||||
action: canCreate ? (
|
||||
<PrimaryButton
|
||||
id="upload-new-file-btn"
|
||||
@ -529,7 +587,10 @@ const FilesList = (): ReactElement => {
|
||||
{/* Search */}
|
||||
<SearchBox
|
||||
value={search}
|
||||
onChange={(v) => { setSearch(v); setCurrentPage(1); }}
|
||||
onChange={(v) => {
|
||||
setSearch(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="Search by name, ID..."
|
||||
/>
|
||||
|
||||
@ -538,13 +599,16 @@ const FilesList = (): ReactElement => {
|
||||
label="Category"
|
||||
options={categoryOptions}
|
||||
value={categoryFilter}
|
||||
onChange={(v) => { setCategoryFilter(v); setCurrentPage(1); }}
|
||||
onChange={(v) => {
|
||||
setCategoryFilter(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Source Module filter */}
|
||||
<FilterDropdown
|
||||
label="Module"
|
||||
options={modules.map(m => ({ value: m.id, label: m.name }))}
|
||||
options={modules.map((m) => ({ value: m.id, label: m.name }))}
|
||||
value={moduleIdFilter}
|
||||
onChange={(val) => {
|
||||
setModuleIdFilter(val as string | null);
|
||||
@ -625,11 +689,16 @@ const FilesList = (): ReactElement => {
|
||||
onChange={(e) => setIsHardDelete(e.target.checked)}
|
||||
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)
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
</DeleteConfirmationModal>
|
||||
|
||||
@ -115,11 +115,11 @@ const Modules = (): ReactElement => {
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'base_url',
|
||||
label: 'Base URL',
|
||||
key: 'frontend_base_url',
|
||||
label: 'Frontend URL',
|
||||
render: (module) => (
|
||||
<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>
|
||||
),
|
||||
mobileLabel: 'URL',
|
||||
@ -166,9 +166,9 @@ const Modules = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{module.base_url || 'N/A'}
|
||||
{module.frontend_base_url || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
FilterDropdown,
|
||||
ActionDropdown,
|
||||
PrimaryButton,
|
||||
DeleteConfirmationModal,
|
||||
} from "@/components/shared";
|
||||
import { Plus, Play, Loader2, Eye, Trash2 } from "lucide-react";
|
||||
import { aiService } from "@/services/ai-service";
|
||||
@ -27,6 +28,9 @@ export const TenantAIProviders = (): ReactElement => {
|
||||
const [testingProviders, setTestingProviders] = useState<Record<string, boolean>>({});
|
||||
const [selectedConfig, setSelectedConfig] = useState<TenantAIConfig | null>(null);
|
||||
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 () => {
|
||||
setIsLoading(true);
|
||||
@ -77,17 +81,27 @@ export const TenantAIProviders = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfig = async (provider: string) => {
|
||||
if (!window.confirm(`Are you sure you want to delete the AI provider configuration for ${provider}?`)) {
|
||||
return;
|
||||
}
|
||||
const handleDeleteConfig = (provider: string) => {
|
||||
setProviderToDelete(provider);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const onConfirmDelete = async () => {
|
||||
if (!providerToDelete) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await aiService.deleteConfig(provider);
|
||||
showToast.success(`${provider} config removed successfully`);
|
||||
await aiService.deleteConfig(providerToDelete);
|
||||
showToast.success(`${providerToDelete} config removed successfully`);
|
||||
void fetchConfigs();
|
||||
setIsDeleteModalOpen(false);
|
||||
} 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);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setProviderToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
@ -300,6 +314,16 @@ export const TenantAIProviders = (): ReactElement => {
|
||||
onClose={() => setIsViewModalOpen(false)}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -116,7 +116,7 @@ export interface MyModule {
|
||||
description: string | null;
|
||||
version: string;
|
||||
status: string;
|
||||
base_url: string;
|
||||
frontend_base_url: string;
|
||||
health_status: string | null;
|
||||
assigned_at: string;
|
||||
tenant_settings: Record<string, unknown> | null;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user