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",
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>
),
},

View File

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

View File

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

View File

@ -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(() => {})
}
/>
))

View File

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

View File

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

View File

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

View File

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