feat: costs UI on tool detail + tier badge in sidebar

This commit is contained in:
root
2026-07-29 23:18:19 +02:00
parent ba413f5ee9
commit 45f0b80055
2 changed files with 191 additions and 10 deletions
+10 -9
View File
@@ -6,7 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton";
export function Layout({ children }: { children: React.ReactNode }) {
const [location] = useLocation();
const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, login, logout } = useAuth();
const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, login, logout } = useAuth();
const links = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
@@ -55,14 +55,15 @@ export function Layout({ children }: { children: React.ReactNode }) {
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center shrink-0">
<User className="w-4 h-4 text-primary" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate text-foreground">
{user.name || user.preferredUsername || "User"}
</p>
{user.email && (
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate text-foreground">
{user.name || user.preferredUsername || "User"}
</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] uppercase tracking-wider font-semibold px-1.5 py-0.5 rounded-sm bg-primary/10 text-primary">{tier}</span>
{user.email && <span className="text-xs text-muted-foreground truncate">{user.email}</span>}
</div>
</div>
</div>
<Button
variant="ghost"
+181 -1
View File
@@ -35,7 +35,7 @@ import {
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2, Link as LinkIcon } from "lucide-react";
import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2, Link as LinkIcon, DollarSign, Euro } from "lucide-react";
import { Link } from "wouter";
import { useAuth } from "@/hooks/use-auth";
import { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react";
@@ -79,6 +79,15 @@ export default function ToolDetail() {
const [linkToolId, setLinkToolId] = useState("");
const [linkType, setLinkType] = useState("similar");
const [linkNotes, setLinkNotes] = useState("");
const [costs, setCosts] = useState<any[]>([]);
const [costsLoading, setCostsLoading] = useState(false);
const [costDialogOpen, setCostDialogOpen] = useState(false);
const [editCost, setEditCost] = useState<any | null>(null);
const [costLicenseType, setCostLicenseType] = useState("subscription");
const [costAmount, setCostAmount] = useState("");
const [costCurrency, setCostCurrency] = useState("EUR");
const [costRenewal, setCostRenewal] = useState("");
const [costNotes, setCostNotes] = useState("");
useEffect(() => {
if (!id) return;
@@ -122,6 +131,70 @@ export default function ToolDetail() {
}
}
useEffect(() => {
if (!id) return;
setCostsLoading(true);
customFetch<any[]>(`/api/tools/${id}/costs`)
.then((data) => setCosts(data ?? []))
.catch(() => {})
.finally(() => setCostsLoading(false));
}, [id]);
async function handleSaveCost() {
const url = editCost ? `/api/costs/${editCost.id}` : `/api/tools/${id}/costs`;
const method = editCost ? "PATCH" : "POST";
const body: Record<string, unknown> = {
licenseType: costLicenseType,
currency: costCurrency,
notes: costNotes || null,
};
if (costAmount) body.cost = costAmount;
if (costRenewal) body.renewalDate = costRenewal;
try {
const res = await customFetch(url, {
method,
body: JSON.stringify(body),
});
if (!res.ok) { const e = await res.json(); throw new Error(e.error); }
toast({ title: editCost ? "Cost updated" : "Cost added" });
setCostDialogOpen(false);
resetCostForm();
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
} catch (err: any) {
toast({ title: "Failed to save cost", description: err.message, variant: "destructive" });
}
}
function resetCostForm() {
setEditCost(null);
setCostLicenseType("subscription");
setCostAmount("");
setCostCurrency("EUR");
setCostRenewal("");
setCostNotes("");
}
function openEditCost(c: any) {
setEditCost(c);
setCostLicenseType(c.licenseType);
setCostAmount(c.cost ?? "");
setCostCurrency(c.currency ?? "EUR");
setCostRenewal(c.renewalDate ?? "");
setCostNotes(c.notes ?? "");
setCostDialogOpen(true);
}
async function handleDeleteCost(costId: number) {
try {
const res = await customFetch(`/api/costs/${costId}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to delete");
toast({ title: "Cost deleted" });
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
} catch {
toast({ title: "Failed to delete cost", variant: "destructive" });
}
}
const { data: tool, isLoading: loadingTool } = useGetTool(id, {
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) }
});
@@ -466,6 +539,113 @@ export default function ToolDetail() {
</DialogContent>
</Dialog>
{/* Costs Section */}
{tool && (
<div className="space-y-4 mt-8">
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold">Costs</h3>
{isAdmin && (
<Button variant="outline" size="sm" onClick={() => { resetCostForm(); setCostDialogOpen(true); }} className="gap-2">
<Plus className="w-4 h-4" /> Add Cost
</Button>
)}
</div>
{costsLoading ? (
<Skeleton className="h-16 rounded-lg" />
) : costs.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{costs.map((c, i) => (
<Card key={c.id ?? i} className="relative group">
<CardContent className="p-4">
<div className="flex justify-between items-start">
<div>
<Badge variant="outline" className="text-xs mb-1">{c.licenseType}</Badge>
<div className="text-lg font-bold">
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : "Free"}
</div>
{c.renewalDate && (
<p className="text-xs text-muted-foreground mt-1">
Renews: {new Date(c.renewalDate).toLocaleDateString()}
</p>
)}
{c.notes && <p className="text-xs text-muted-foreground mt-1 italic">{c.notes}</p>}
</div>
{isAdmin && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditCost(c)}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => handleDeleteCost(c.id)}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<p className="text-sm text-muted-foreground py-4">No cost information added yet.</p>
)}
</div>
)}
{/* Cost Dialog */}
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editCost ? "Edit Cost" : "Add Cost"}</DialogTitle>
<DialogDescription>Manage license cost information for this tool.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">License Type</label>
<Select value={costLicenseType} onValueChange={setCostLicenseType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="subscription">Subscription</SelectItem>
<SelectItem value="one_time">One-Time</SelectItem>
<SelectItem value="usage_based">Usage-Based</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Cost</label>
<Input type="number" step="0.01" placeholder="0.00" value={costAmount} onChange={(e) => setCostAmount(e.target.value)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Currency</label>
<Select value={costCurrency} onValueChange={setCostCurrency}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
<SelectItem value="CHF">CHF</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Renewal Date</label>
<Input type="date" value={costRenewal} onChange={(e) => setCostRenewal(e.target.value)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Notes</label>
<Textarea placeholder="Billing details, contract info..." value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCostDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSaveCost}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Ratings & Reviews Section */}
{tool && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">