feat: add adapter picker for imported agents
When importing a company, users can now choose the adapter type for each imported agent. Defaults to the current company CEO's adapter type (or claude_local if none). Includes an expandable "configure adapter" section per agent that renders the adapter-specific config fields. - Added adapterOverrides to import request schema and types - Built AdapterPickerList UI component in CompanyImport.tsx - Backend applies adapter overrides when creating/updating agents Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
cf8bfe8d8e
commit
1548b73b77
6 changed files with 243 additions and 11 deletions
|
|
@ -185,6 +185,7 @@ export type {
|
||||||
CompanyPortabilityPreviewProjectPlan,
|
CompanyPortabilityPreviewProjectPlan,
|
||||||
CompanyPortabilityPreviewIssuePlan,
|
CompanyPortabilityPreviewIssuePlan,
|
||||||
CompanyPortabilityPreviewResult,
|
CompanyPortabilityPreviewResult,
|
||||||
|
CompanyPortabilityAdapterOverride,
|
||||||
CompanyPortabilityImportRequest,
|
CompanyPortabilityImportRequest,
|
||||||
CompanyPortabilityImportResult,
|
CompanyPortabilityImportResult,
|
||||||
CompanyPortabilityExportRequest,
|
CompanyPortabilityExportRequest,
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,14 @@ export interface CompanyPortabilityPreviewResult {
|
||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyPortabilityImportRequest extends CompanyPortabilityPreviewRequest {}
|
export interface CompanyPortabilityAdapterOverride {
|
||||||
|
adapterType: string;
|
||||||
|
adapterConfig?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyPortabilityImportRequest extends CompanyPortabilityPreviewRequest {
|
||||||
|
adapterOverrides?: Record<string, CompanyPortabilityAdapterOverride>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CompanyPortabilityImportResult {
|
export interface CompanyPortabilityImportResult {
|
||||||
company: {
|
company: {
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ export type {
|
||||||
CompanyPortabilityPreviewProjectPlan,
|
CompanyPortabilityPreviewProjectPlan,
|
||||||
CompanyPortabilityPreviewIssuePlan,
|
CompanyPortabilityPreviewIssuePlan,
|
||||||
CompanyPortabilityPreviewResult,
|
CompanyPortabilityPreviewResult,
|
||||||
|
CompanyPortabilityAdapterOverride,
|
||||||
CompanyPortabilityImportRequest,
|
CompanyPortabilityImportRequest,
|
||||||
CompanyPortabilityImportResult,
|
CompanyPortabilityImportResult,
|
||||||
CompanyPortabilityExportRequest,
|
CompanyPortabilityExportRequest,
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,13 @@ export const companyPortabilityPreviewSchema = z.object({
|
||||||
|
|
||||||
export type CompanyPortabilityPreview = z.infer<typeof companyPortabilityPreviewSchema>;
|
export type CompanyPortabilityPreview = z.infer<typeof companyPortabilityPreviewSchema>;
|
||||||
|
|
||||||
export const companyPortabilityImportSchema = companyPortabilityPreviewSchema;
|
export const portabilityAdapterOverrideSchema = z.object({
|
||||||
|
adapterType: z.string().min(1),
|
||||||
|
adapterConfig: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const companyPortabilityImportSchema = companyPortabilityPreviewSchema.extend({
|
||||||
|
adapterOverrides: z.record(z.string().min(1), portabilityAdapterOverrideSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export type CompanyPortabilityImport = z.infer<typeof companyPortabilityImportSchema>;
|
export type CompanyPortabilityImport = z.infer<typeof companyPortabilityImportSchema>;
|
||||||
|
|
|
||||||
|
|
@ -2260,16 +2260,21 @@ export function companyPortabilityService(db: Db) {
|
||||||
warnings.push(`Missing AGENTS markdown for ${manifestAgent.slug}; imported without prompt template.`);
|
warnings.push(`Missing AGENTS markdown for ${manifestAgent.slug}; imported without prompt template.`);
|
||||||
}
|
}
|
||||||
const markdown = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : { frontmatter: {}, body: "" };
|
const markdown = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : { frontmatter: {}, body: "" };
|
||||||
const adapterConfig = {
|
const promptTemplate = markdown.body || asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "";
|
||||||
...manifestAgent.adapterConfig,
|
|
||||||
promptTemplate: markdown.body || asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "",
|
// Apply adapter overrides from request if present
|
||||||
} as Record<string, unknown>;
|
const adapterOverride = input.adapterOverrides?.[planAgent.slug];
|
||||||
|
const effectiveAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType;
|
||||||
|
const baseAdapterConfig = adapterOverride?.adapterConfig
|
||||||
|
? { ...adapterOverride.adapterConfig, promptTemplate }
|
||||||
|
: { ...manifestAgent.adapterConfig, promptTemplate } as Record<string, unknown>;
|
||||||
|
|
||||||
const desiredSkills = manifestAgent.skills ?? [];
|
const desiredSkills = manifestAgent.skills ?? [];
|
||||||
const adapterConfigWithSkills = writePaperclipSkillSyncPreference(
|
const adapterConfigWithSkills = writePaperclipSkillSyncPreference(
|
||||||
adapterConfig,
|
baseAdapterConfig,
|
||||||
desiredSkills,
|
desiredSkills,
|
||||||
);
|
);
|
||||||
delete adapterConfig.instructionsFilePath;
|
delete baseAdapterConfig.instructionsFilePath;
|
||||||
const patch = {
|
const patch = {
|
||||||
name: planAgent.plannedName,
|
name: planAgent.plannedName,
|
||||||
role: manifestAgent.role,
|
role: manifestAgent.role,
|
||||||
|
|
@ -2277,7 +2282,7 @@ export function companyPortabilityService(db: Db) {
|
||||||
icon: manifestAgent.icon,
|
icon: manifestAgent.icon,
|
||||||
capabilities: manifestAgent.capabilities,
|
capabilities: manifestAgent.capabilities,
|
||||||
reportsTo: null,
|
reportsTo: null,
|
||||||
adapterType: manifestAgent.adapterType,
|
adapterType: effectiveAdapterType,
|
||||||
adapterConfig: adapterConfigWithSkills,
|
adapterConfig: adapterConfigWithSkills,
|
||||||
runtimeConfig: manifestAgent.runtimeConfig,
|
runtimeConfig: manifestAgent.runtimeConfig,
|
||||||
budgetMonthlyCents: manifestAgent.budgetMonthlyCents,
|
budgetMonthlyCents: manifestAgent.budgetMonthlyCents,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type {
|
import type {
|
||||||
CompanyPortabilityPreviewResult,
|
CompanyPortabilityPreviewResult,
|
||||||
CompanyPortabilitySource,
|
CompanyPortabilitySource,
|
||||||
|
CompanyPortabilityAdapterOverride,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { MarkdownBody } from "../components/MarkdownBody";
|
import { MarkdownBody } from "../components/MarkdownBody";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -16,12 +18,16 @@ import { cn } from "../lib/utils";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Check,
|
Check,
|
||||||
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
Github,
|
Github,
|
||||||
Package,
|
Package,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Field } from "../components/agent-config-primitives";
|
import { Field, adapterLabels } from "../components/agent-config-primitives";
|
||||||
|
import { defaultCreateValues } from "../components/agent-config-defaults";
|
||||||
|
import { getUIAdapter } from "../adapters";
|
||||||
|
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
type FileTreeNode,
|
type FileTreeNode,
|
||||||
type FrontmatterData,
|
type FrontmatterData,
|
||||||
|
|
@ -434,6 +440,120 @@ function ConflictResolutionList({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Adapter type options for import ───────────────────────────────────
|
||||||
|
|
||||||
|
const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: "claude_local", label: adapterLabels.claude_local ?? "Claude (local)" },
|
||||||
|
{ value: "codex_local", label: adapterLabels.codex_local ?? "Codex (local)" },
|
||||||
|
{ value: "opencode_local", label: adapterLabels.opencode_local ?? "OpenCode (local)" },
|
||||||
|
{ value: "cursor", label: adapterLabels.cursor ?? "Cursor (local)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Adapter picker for imported agents ───────────────────────────────
|
||||||
|
|
||||||
|
interface AdapterPickerItem {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
adapterType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdapterPickerList({
|
||||||
|
agents,
|
||||||
|
adapterOverrides,
|
||||||
|
expandedSlugs,
|
||||||
|
configValues,
|
||||||
|
onChangeAdapter,
|
||||||
|
onToggleExpand,
|
||||||
|
onChangeConfig,
|
||||||
|
}: {
|
||||||
|
agents: AdapterPickerItem[];
|
||||||
|
adapterOverrides: Record<string, string>;
|
||||||
|
expandedSlugs: Set<string>;
|
||||||
|
configValues: Record<string, CreateConfigValues>;
|
||||||
|
onChangeAdapter: (slug: string, adapterType: string) => void;
|
||||||
|
onToggleExpand: (slug: string) => void;
|
||||||
|
onChangeConfig: (slug: string, patch: Partial<CreateConfigValues>) => void;
|
||||||
|
}) {
|
||||||
|
if (agents.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-5 mt-3">
|
||||||
|
<div className="rounded-md border border-border">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||||
|
<h3 className="text-sm font-medium">Adapters</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{agents.length} agent{agents.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{agents.map((agent) => {
|
||||||
|
const selectedType = adapterOverrides[agent.slug] ?? agent.adapterType;
|
||||||
|
const isExpanded = expandedSlugs.has(agent.slug);
|
||||||
|
const uiAdapter = getUIAdapter(selectedType);
|
||||||
|
const vals = configValues[agent.slug] ?? { ...defaultCreateValues, adapterType: selectedType };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={agent.slug}>
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
||||||
|
<span className={cn(
|
||||||
|
"shrink-0 rounded-full border px-2 py-0.5 text-[10px] uppercase tracking-wide",
|
||||||
|
"text-blue-500 border-blue-500/30",
|
||||||
|
)}>
|
||||||
|
agent
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
|
{agent.name}
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
|
<select
|
||||||
|
className="min-w-0 flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-xs outline-none focus:border-foreground"
|
||||||
|
value={selectedType}
|
||||||
|
onChange={(e) => onChangeAdapter(agent.slug, e.target.value)}
|
||||||
|
>
|
||||||
|
{IMPORT_ADAPTER_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto shrink-0 rounded-md border px-2.5 py-1 text-xs transition-colors inline-flex items-center gap-1.5",
|
||||||
|
isExpanded
|
||||||
|
? "border-foreground bg-accent text-foreground"
|
||||||
|
: "border-border text-muted-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
onClick={() => onToggleExpand(agent.slug)}
|
||||||
|
>
|
||||||
|
<ChevronRight className={cn("h-3 w-3 transition-transform", isExpanded && "rotate-90")} />
|
||||||
|
configure adapter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-border bg-accent/10 px-4 py-3 space-y-3">
|
||||||
|
<uiAdapter.ConfigFields
|
||||||
|
mode="create"
|
||||||
|
isCreate
|
||||||
|
adapterType={selectedType}
|
||||||
|
values={vals}
|
||||||
|
set={(patch) => onChangeConfig(agent.slug, patch)}
|
||||||
|
config={{}}
|
||||||
|
eff={() => "" as any}
|
||||||
|
mark={() => {}}
|
||||||
|
models={[]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function readLocalPackageZip(file: File): Promise<{
|
async function readLocalPackageZip(file: File): Promise<{
|
||||||
|
|
@ -493,6 +613,23 @@ export function CompanyImport() {
|
||||||
const [skippedSlugs, setSkippedSlugs] = useState<Set<string>>(new Set());
|
const [skippedSlugs, setSkippedSlugs] = useState<Set<string>>(new Set());
|
||||||
const [confirmedSlugs, setConfirmedSlugs] = useState<Set<string>>(new Set());
|
const [confirmedSlugs, setConfirmedSlugs] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Adapter override state
|
||||||
|
const [adapterOverrides, setAdapterOverrides] = useState<Record<string, string>>({});
|
||||||
|
const [adapterExpandedSlugs, setAdapterExpandedSlugs] = useState<Set<string>>(new Set());
|
||||||
|
const [adapterConfigValues, setAdapterConfigValues] = useState<Record<string, CreateConfigValues>>({});
|
||||||
|
|
||||||
|
// Fetch current company agents to find CEO adapter type
|
||||||
|
const { data: companyAgents } = useQuery({
|
||||||
|
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none"],
|
||||||
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
|
enabled: Boolean(selectedCompanyId),
|
||||||
|
});
|
||||||
|
const ceoAdapterType = useMemo(() => {
|
||||||
|
if (!companyAgents) return "claude_local";
|
||||||
|
const ceo = companyAgents.find((a) => a.role === "ceo");
|
||||||
|
return ceo?.adapterType ?? "claude_local";
|
||||||
|
}, [companyAgents]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([
|
setBreadcrumbs([
|
||||||
{ label: "Org Chart", href: "/org" },
|
{ label: "Org Chart", href: "/org" },
|
||||||
|
|
@ -548,6 +685,15 @@ export function CompanyImport() {
|
||||||
setSkippedSlugs(new Set());
|
setSkippedSlugs(new Set());
|
||||||
setConfirmedSlugs(new Set());
|
setConfirmedSlugs(new Set());
|
||||||
|
|
||||||
|
// Initialize adapter overrides — default all agents to the CEO's adapter type
|
||||||
|
const defaultAdapters: Record<string, string> = {};
|
||||||
|
for (const agent of result.manifest.agents) {
|
||||||
|
defaultAdapters[agent.slug] = ceoAdapterType;
|
||||||
|
}
|
||||||
|
setAdapterOverrides(defaultAdapters);
|
||||||
|
setAdapterExpandedSlugs(new Set());
|
||||||
|
setAdapterConfigValues({});
|
||||||
|
|
||||||
// Check all files by default, then uncheck COMPANY.md for existing company
|
// Check all files by default, then uncheck COMPANY.md for existing company
|
||||||
const allFiles = new Set(Object.keys(result.files));
|
const allFiles = new Set(Object.keys(result.files));
|
||||||
if (targetMode === "existing" && result.manifest.company && result.plan.companyAction === "update") {
|
if (targetMode === "existing" && result.manifest.company && result.plan.companyAction === "update") {
|
||||||
|
|
@ -620,6 +766,7 @@ export function CompanyImport() {
|
||||||
collisionStrategy: "rename",
|
collisionStrategy: "rename",
|
||||||
nameOverrides: buildFinalNameOverrides(),
|
nameOverrides: buildFinalNameOverrides(),
|
||||||
selectedFiles: buildSelectedFiles(),
|
selectedFiles: buildSelectedFiles(),
|
||||||
|
adapterOverrides: buildFinalAdapterOverrides(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
|
|
@ -786,6 +933,59 @@ export function CompanyImport() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAdapterChange(slug: string, adapterType: string) {
|
||||||
|
setAdapterOverrides((prev) => ({ ...prev, [slug]: adapterType }));
|
||||||
|
// Reset config values when adapter type changes
|
||||||
|
setAdapterConfigValues((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[slug];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdapterToggleExpand(slug: string) {
|
||||||
|
setAdapterExpandedSlugs((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(slug)) next.delete(slug);
|
||||||
|
else next.add(slug);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdapterConfigChange(slug: string, patch: Partial<CreateConfigValues>) {
|
||||||
|
setAdapterConfigValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[slug]: { ...(prev[slug] ?? { ...defaultCreateValues, adapterType: adapterOverrides[slug] ?? "claude_local" }), ...patch },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the list of agents for adapter picking
|
||||||
|
const adapterAgents = useMemo<AdapterPickerItem[]>(() => {
|
||||||
|
if (!importPreview) return [];
|
||||||
|
return importPreview.manifest.agents.map((a) => ({
|
||||||
|
slug: a.slug,
|
||||||
|
name: a.name,
|
||||||
|
adapterType: a.adapterType,
|
||||||
|
}));
|
||||||
|
}, [importPreview]);
|
||||||
|
|
||||||
|
// Build final adapterOverrides for import request
|
||||||
|
function buildFinalAdapterOverrides(): Record<string, CompanyPortabilityAdapterOverride> | undefined {
|
||||||
|
if (adapterAgents.length === 0) return undefined;
|
||||||
|
const overrides: Record<string, CompanyPortabilityAdapterOverride> = {};
|
||||||
|
for (const agent of adapterAgents) {
|
||||||
|
const selectedType = adapterOverrides[agent.slug] ?? agent.adapterType;
|
||||||
|
const configVals = adapterConfigValues[agent.slug];
|
||||||
|
const override: CompanyPortabilityAdapterOverride = { adapterType: selectedType };
|
||||||
|
if (configVals) {
|
||||||
|
const uiAdapter = getUIAdapter(selectedType);
|
||||||
|
override.adapterConfig = uiAdapter.buildAdapterConfig(configVals);
|
||||||
|
}
|
||||||
|
overrides[agent.slug] = override;
|
||||||
|
}
|
||||||
|
return Object.keys(overrides).length > 0 ? overrides : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const hasSource =
|
const hasSource =
|
||||||
sourceMode === "local" ? !!localPackage : importUrl.trim().length > 0;
|
sourceMode === "local" ? !!localPackage : importUrl.trim().length > 0;
|
||||||
const hasErrors = importPreview ? importPreview.errors.length > 0 : false;
|
const hasErrors = importPreview ? importPreview.errors.length > 0 : false;
|
||||||
|
|
@ -967,6 +1167,17 @@ export function CompanyImport() {
|
||||||
onToggleConfirm={handleConflictToggleConfirm}
|
onToggleConfirm={handleConflictToggleConfirm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Adapter picker list */}
|
||||||
|
<AdapterPickerList
|
||||||
|
agents={adapterAgents}
|
||||||
|
adapterOverrides={adapterOverrides}
|
||||||
|
expandedSlugs={adapterExpandedSlugs}
|
||||||
|
configValues={adapterConfigValues}
|
||||||
|
onChangeAdapter={handleAdapterChange}
|
||||||
|
onToggleExpand={handleAdapterToggleExpand}
|
||||||
|
onChangeConfig={handleAdapterConfigChange}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Import button — below renames */}
|
{/* Import button — below renames */}
|
||||||
<div className="mx-5 mt-3 flex justify-end">
|
<div className="mx-5 mt-3 flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue