feat: costs UI on tool detail + tier badge in sidebar
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user