feat(11-04): extend AgentSkillsTab with groups section and dialogs
- Add imports: skillGroupsApi, SkillGroupRow, GroupBadge, Dialog, Separator, ScrollArea, Textarea - Add agentGroups + allGroups + agentEffectiveSkills queries in AgentSkillsTab - Add assignGroup + removeGroup + createGroup mutations - Add state for add/create/remove dialogs, search, new group fields - Insert Assigned Groups section with loading/empty/populated states - Insert Combined Effective Skills collapsible section with ScrollArea - Insert Additional Individual Skills label above existing skill list - Add Add Skill Group, Create Skill Group, Remove group from agent dialogs - Add Skill Groups section to DesignGuide with all GroupBadge variants
This commit is contained in:
parent
609c5b30b7
commit
4c771b8051
2 changed files with 399 additions and 0 deletions
|
|
@ -9,6 +9,7 @@ import {
|
||||||
type AgentPermissionUpdate,
|
type AgentPermissionUpdate,
|
||||||
} from "../api/agents";
|
} from "../api/agents";
|
||||||
import { companySkillsApi } from "../api/companySkills";
|
import { companySkillsApi } from "../api/companySkills";
|
||||||
|
import { skillGroupsApi, type SkillGroupRow } from "../api/skillGroups";
|
||||||
import { budgetsApi } from "../api/budgets";
|
import { budgetsApi } from "../api/budgets";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
|
|
@ -75,6 +76,17 @@ import {
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { GroupBadge } from "../components/GroupBadge";
|
||||||
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||||
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
||||||
import {
|
import {
|
||||||
|
|
@ -2400,6 +2412,63 @@ function AgentSkillsTab({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Skill groups queries
|
||||||
|
const agentGroupsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.skillGroups.agentGroups(agent.id),
|
||||||
|
queryFn: () => skillGroupsApi.listAgentGroups(agent.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const allGroupsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.skillGroups.list,
|
||||||
|
queryFn: () => skillGroupsApi.listGroups(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentEffectiveSkillsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.skillGroups.agentSkills(agent.id),
|
||||||
|
queryFn: () => skillGroupsApi.listAgentSkills(agent.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group dialog state
|
||||||
|
const [addGroupOpen, setAddGroupOpen] = useState(false);
|
||||||
|
const [createGroupOpen, setCreateGroupOpen] = useState(false);
|
||||||
|
const [removeGroupConfirm, setRemoveGroupConfirm] = useState<SkillGroupRow | null>(null);
|
||||||
|
const [groupSearch, setGroupSearch] = useState("");
|
||||||
|
const [newGroupName, setNewGroupName] = useState("");
|
||||||
|
const [newGroupDesc, setNewGroupDesc] = useState("");
|
||||||
|
const [effectiveOpen, setEffectiveOpen] = useState(false);
|
||||||
|
|
||||||
|
// Group mutations
|
||||||
|
const assignGroupMut = useMutation({
|
||||||
|
mutationFn: ({ groupId }: { groupId: string }) =>
|
||||||
|
skillGroupsApi.assignGroup(agent.id, groupId, ""),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentGroups(agent.id) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentSkills(agent.id) });
|
||||||
|
setAddGroupOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeGroupMut = useMutation({
|
||||||
|
mutationFn: ({ groupId }: { groupId: string }) =>
|
||||||
|
skillGroupsApi.removeGroup(agent.id, groupId, ""),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentGroups(agent.id) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.agentSkills(agent.id) });
|
||||||
|
setRemoveGroupConfirm(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createGroupMut = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
skillGroupsApi.createGroup({ name: newGroupName, description: newGroupDesc || undefined }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.skillGroups.list });
|
||||||
|
setCreateGroupOpen(false);
|
||||||
|
setNewGroupName("");
|
||||||
|
setNewGroupDesc("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSkillDraft([]);
|
setSkillDraft([]);
|
||||||
setLastSavedSkills([]);
|
setLastSavedSkills([]);
|
||||||
|
|
@ -2573,6 +2642,289 @@ function AgentSkillsTab({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* ---- Assigned Groups Section ---- */}
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Assigned Groups
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{agentGroupsQuery.isLoading ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Skeleton className="h-6 w-24 rounded-full" />
|
||||||
|
<Skeleton className="h-6 w-28 rounded-full" />
|
||||||
|
<Skeleton className="h-6 w-20 rounded-full" />
|
||||||
|
</div>
|
||||||
|
) : agentGroupsQuery.data?.length === 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">No groups assigned</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Add a skill group to install a bundle of skills for this agent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<TooltipProvider>
|
||||||
|
{(agentGroupsQuery.data ?? []).map((group) => (
|
||||||
|
<GroupBadge
|
||||||
|
key={group.id}
|
||||||
|
name={group.name}
|
||||||
|
isBuiltin={group.isBuiltin === 1}
|
||||||
|
onRemove={
|
||||||
|
group.isBuiltin === 1
|
||||||
|
? undefined
|
||||||
|
: () => setRemoveGroupConfirm(group)
|
||||||
|
}
|
||||||
|
removing={
|
||||||
|
removeGroupMut.isPending && removeGroupConfirm?.id === group.id
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAddGroupOpen(true)}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Add Group
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{agentGroupsQuery.isError && (
|
||||||
|
<p className="text-sm text-destructive">Failed to load groups. Try again.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* ---- Combined Effective Skills Section ---- */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Collapsible open={effectiveOpen} onOpenChange={setEffectiveOpen}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Combined Effective Skills
|
||||||
|
</h3>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs">
|
||||||
|
{effectiveOpen
|
||||||
|
? "Hide skills"
|
||||||
|
: `Show ${agentEffectiveSkillsQuery.data?.length ?? 0} skills`}
|
||||||
|
<ChevronDown
|
||||||
|
className={cn("ml-1 h-3.5 w-3.5 transition-transform", effectiveOpen && "rotate-180")}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
{agentEffectiveSkillsQuery.isLoading ? (
|
||||||
|
<div className="space-y-1.5 pt-2">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
</div>
|
||||||
|
) : agentEffectiveSkillsQuery.data?.length === 0 ? (
|
||||||
|
<p className="pt-2 text-sm text-muted-foreground">
|
||||||
|
No skills in assigned groups. Add skills to the group definitions first.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="max-h-[300px] pt-2">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{(agentEffectiveSkillsQuery.data ?? []).map((skillId) => (
|
||||||
|
<li key={skillId} className="text-sm text-muted-foreground font-mono">
|
||||||
|
{skillId}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* ---- Additional Individual Skills ---- */}
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Additional Individual Skills
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* ---- Add Group Dialog ---- */}
|
||||||
|
<Dialog open={addGroupOpen} onOpenChange={setAddGroupOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Skill Group</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Search groups..."
|
||||||
|
value={groupSearch}
|
||||||
|
onChange={(e) => setGroupSearch(e.target.value)}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<div className="max-h-64 overflow-y-auto divide-y divide-border rounded-md border border-border">
|
||||||
|
{(() => {
|
||||||
|
const assignedIds = new Set((agentGroupsQuery.data ?? []).map((g) => g.id));
|
||||||
|
const available = (allGroupsQuery.data ?? []).filter(
|
||||||
|
(g) =>
|
||||||
|
!assignedIds.has(g.id) &&
|
||||||
|
g.name.toLowerCase().includes(groupSearch.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (available.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
|
{groupSearch ? "No groups match your search." : "No groups available to add."}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return available.map((group) => (
|
||||||
|
<div key={group.id} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold truncate">{group.name}</p>
|
||||||
|
{group.isBuiltin === 1 && (
|
||||||
|
<p className="text-xs text-muted-foreground">built-in</p>
|
||||||
|
)}
|
||||||
|
{group.description && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{group.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={assignGroupMut.isPending}
|
||||||
|
onClick={() => assignGroupMut.mutate({ groupId: group.id })}
|
||||||
|
className="shrink-0 h-7 text-xs"
|
||||||
|
>
|
||||||
|
{assignGroupMut.isPending ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Assign group"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
{assignGroupMut.isError && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Failed to assign group. Check the server logs for details.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setAddGroupOpen(false);
|
||||||
|
setCreateGroupOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New Group
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setAddGroupOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ---- Create Group Dialog ---- */}
|
||||||
|
<Dialog open={createGroupOpen} onOpenChange={setCreateGroupOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Skill Group</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-semibold" htmlFor="new-group-name">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="new-group-name"
|
||||||
|
placeholder="Group name"
|
||||||
|
value={newGroupName}
|
||||||
|
onChange={(e) => setNewGroupName(e.target.value)}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-semibold" htmlFor="new-group-desc">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="new-group-desc"
|
||||||
|
placeholder="Optional description"
|
||||||
|
value={newGroupDesc}
|
||||||
|
onChange={(e) => setNewGroupDesc(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{createGroupMut.isError && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Failed to create group. Check the server logs for details.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateGroupOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!newGroupName.trim() || createGroupMut.isPending}
|
||||||
|
onClick={() => createGroupMut.mutate()}
|
||||||
|
>
|
||||||
|
{createGroupMut.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Create Group
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ---- Remove Group Confirmation Dialog ---- */}
|
||||||
|
<Dialog
|
||||||
|
open={removeGroupConfirm !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setRemoveGroupConfirm(null); }}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remove group from agent</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Removing{" "}
|
||||||
|
<span className="font-semibold text-foreground">{removeGroupConfirm?.name}</span>{" "}
|
||||||
|
will uninstall skills that are not used by any other assigned group.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRemoveGroupConfirm(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={removeGroupMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (removeGroupConfirm) {
|
||||||
|
removeGroupMut.mutate({ groupId: removeGroupConfirm.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{removeGroupMut.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Remove group
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<PageSkeleton variant="list" />
|
<PageSkeleton variant="list" />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -125,6 +126,7 @@ import { InlineEditor } from "@/components/InlineEditor";
|
||||||
import { PageSkeleton } from "@/components/PageSkeleton";
|
import { PageSkeleton } from "@/components/PageSkeleton";
|
||||||
import { Identity } from "@/components/Identity";
|
import { Identity } from "@/components/Identity";
|
||||||
import { SkillCard } from "@/components/SkillCard";
|
import { SkillCard } from "@/components/SkillCard";
|
||||||
|
import { GroupBadge } from "@/components/GroupBadge";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Section wrapper */
|
/* Section wrapper */
|
||||||
|
|
@ -1400,6 +1402,51 @@ export function DesignGuide() {
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* ============================================================ */}
|
||||||
|
{/* SKILL GROUPS */}
|
||||||
|
{/* ============================================================ */}
|
||||||
|
<Section title="Skill Groups">
|
||||||
|
<SubSection title="GroupBadge — built-in (no dismiss)">
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<GroupBadge name="PM Essentials" isBuiltin={true} skillCount={5} />
|
||||||
|
<GroupBadge name="Engineer Core" isBuiltin={true} skillCount={8} />
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</SubSection>
|
||||||
|
<SubSection title="GroupBadge — custom (with dismiss)">
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<GroupBadge
|
||||||
|
name="Creative"
|
||||||
|
isBuiltin={false}
|
||||||
|
skillCount={3}
|
||||||
|
onRemove={() => undefined}
|
||||||
|
/>
|
||||||
|
<GroupBadge
|
||||||
|
name="Frontend Tools"
|
||||||
|
isBuiltin={false}
|
||||||
|
skillCount={6}
|
||||||
|
onRemove={() => undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</SubSection>
|
||||||
|
<SubSection title="GroupBadge — loading state (removing)">
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<GroupBadge
|
||||||
|
name="Creative"
|
||||||
|
isBuiltin={false}
|
||||||
|
skillCount={3}
|
||||||
|
onRemove={() => undefined}
|
||||||
|
removing={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</SubSection>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* ============================================================ */}
|
{/* ============================================================ */}
|
||||||
{/* KEYBOARD SHORTCUTS */}
|
{/* KEYBOARD SHORTCUTS */}
|
||||||
{/* ============================================================ */}
|
{/* ============================================================ */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue