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",
|
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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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(() => {})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user