Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,134 @@
import { useSubmit, useNavigation, useParams, useSearchParams, Link } from "react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import RefreshIcon from "~/components/ui/icons/RefreshIcon";
import InputFilters from "~/components/ui/input/InputFilters";
import InputSearchWithURL from "~/components/ui/input/InputSearchWithURL";
import { FilterableValueLink } from "~/components/ui/links/FilterableValueLink";
import TableSimple from "~/components/ui/tables/TableSimple";
import { useLocaleFormatters } from "~/locale/format";
import { CreditWithDetails } from "../db/credits.db.server";
import { CreditTypes } from "../dtos/CreditType";
interface Props {
data: {
items: CreditWithDetails[];
filterableProperties: FilterablePropertyDto[];
pagination: PaginationDto;
canDelete: boolean;
};
}
export default function CreditsList({ data }: Props) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const submit = useSubmit();
const navigation = useNavigation();
const params = useParams();
const canDelete = data.canDelete;
const [searchParams, setSearchParams] = useSearchParams();
const [selectedRows, setSelectedRows] = useState<CreditWithDetails[]>([]);
function onDelete(ids: string[]) {
if (!canDelete) {
return;
}
const form = new FormData();
form.set("action", "delete");
form.set("ids", ids.join(","));
submit(form, {
method: "post",
});
}
return (
<div className="space-y-2">
<div className="flex w-full items-center space-x-2">
<div className="grow">
<InputSearchWithURL />
</div>
{canDelete && selectedRows.length > 0 && (
<ButtonSecondary
destructive
onClick={() => {
onDelete(selectedRows.map((x) => x.id));
setSelectedRows([]);
}}
>
{t("shared.delete")} {selectedRows.length}
</ButtonSecondary>
)}
<ButtonSecondary onClick={() => setSearchParams(searchParams)} isLoading={navigation.state === "loading"}>
<RefreshIcon className="h-4 w-4" />
</ButtonSecondary>
<InputFilters filters={data.filterableProperties} />
</div>
<TableSimple
selectedRows={canDelete ? selectedRows : undefined}
onSelected={canDelete ? setSelectedRows : undefined}
items={data.items}
pagination={data.pagination}
actions={[
{
title: t("shared.delete"),
onClick: (_, item) => onDelete([item.id]),
hidden: () => !canDelete,
disabled: () => !canDelete,
destructive: true,
},
]}
headers={[
{
name: "createdAt",
title: t("shared.createdAt"),
value: (item) => formatters.dateTime(item.createdAt, { timeStyle: "medium" }),
formattedValue: (item) => (
<div className="text-muted-foreground text-xs">
{item.createdAt && <span>{formatters.dateTime(item.createdAt, { timeStyle: "medium" })}</span>}
</div>
),
},
{
name: "tenant",
title: t("models.tenant.object"),
value: (item) => <FilterableValueLink name="tenantId" value={item?.tenant?.name} param={item?.tenant?.id} />,
hidden: !!params.tenant,
},
{
name: "type",
title: t("shared.type"),
value: (item) => {
const creditType = CreditTypes.find((type) => type.value === item.type);
return <div>{creditType ? t(creditType.name) : t("models.credit.types.unknown", { type: item.type })}</div>;
},
},
{
name: "resource",
title: t("models.credit.resource"),
className: "w-full",
value: (item) => (
<div className="max-w-xs truncate">
{item.objectId ? (
<Link to={item.objectId} className="truncate underline">
{item.objectId}
</Link>
) : (
<span className="truncate">{t("shared.notSet")}</span>
)}
</div>
),
},
{
name: "user",
title: t("models.user.object"),
value: (item) => <FilterableValueLink name="userId" value={item.user?.email} param={item.user?.id} />,
},
]}
/>
</div>
);
}
@@ -0,0 +1,27 @@
import { Link } from "react-router";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { useLocaleFormatters } from "~/locale/format";
export default function CreditsRemaining({ remaining, redirectTo }: { remaining?: number | "unlimited"; redirectTo: string }) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<Fragment>
{!!remaining && (
<Link
to={redirectTo}
className="cursor-pointer select-none rounded-full border border-green-200 bg-green-50 px-2 py-1 text-xs text-green-700 hover:bg-green-100"
>
{remaining === "unlimited" ? (
<span>{t("models.credit.unlimited")}</span>
) : remaining <= 0 ? (
<span>{t("models.credit.empty")}</span>
) : (
<span>{t("models.credit.remaining", { 0: formatters.number(remaining) })}</span>
)}
</Link>
)}
</Fragment>
);
}
@@ -0,0 +1,39 @@
import TableSimple from "~/components/ui/tables/TableSimple";
import { CreditTypes } from "../dtos/CreditType";
import { useTranslation } from "react-i18next";
import { useLocaleFormatters } from "~/locale/format";
export default function CreditsTableInfo() {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-lg font-medium">{t("models.credit.whatIs")}</h3>
<p className="text-muted-foreground text-sm">{t("models.credit.info")}</p>
</div>
<TableSimple
items={CreditTypes}
headers={[
{
name: "name",
title: t("shared.name"),
className: "w-full",
value: (item) => (
<div className="flex flex-col">
<div className="font-bold">{t(item.name)}</div>
<div className="text-muted-foreground text-xs">{t(item.description)}</div>
</div>
),
},
{
name: "amount",
title: t("models.credit.plural"),
align: "right",
value: (item) => formatters.number(item.amount),
},
]}
/>
</div>
);
}
@@ -0,0 +1,36 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
function readUsageComponent(fileName: string) {
return fs.readFileSync(path.join(root, "apps/web/app/modules/usage/components", fileName), "utf8");
}
describe("usage credit formatting i18n", () => {
it("uses locale-aware formatters for credit list timestamps", () => {
const source = readUsageComponent("CreditsList.tsx");
expect(source).not.toContain("DateUtils");
expect(source).not.toContain('title: "Tenant"');
expect(source).toContain("useLocaleFormatters");
expect(source).toContain('t("models.tenant.object")');
expect(source).toContain('t("shared.notSet")');
expect(source).toContain('t(creditType.name)');
expect(source).toContain('t("models.credit.types.unknown"');
expect(source).toContain("formatters.dateTime");
expect(source).not.toContain('t("shared.undefined")');
expect(source).not.toContain("<div>{item.type}</div>");
});
it("uses locale-aware formatters for credit amounts", () => {
const sources = ["CreditsRemaining.tsx", "CreditsTableInfo.tsx"].map(readUsageComponent).join("\n");
expect(sources).not.toContain("NumberUtils");
expect(sources).toContain("useLocaleFormatters");
expect(sources).toContain("formatters.number");
expect(sources).toContain("t(item.name)");
expect(sources).toContain("t(item.description)");
});
});
@@ -0,0 +1,121 @@
import { Credit, Prisma } from "@toolctrl/db/prisma-browser";
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
import { FiltersDto } from "~/application/dtos/data/FiltersDto";
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
import { db } from "~/utils/db.server";
import { TenantSimple } from "~/utils/db/tenants.db.server";
import { UserSimple } from "~/utils/db/users.db.server";
import TenantModelHelper from "~/utils/helpers/models/TenantModelHelper";
import UserModelHelper from "~/utils/helpers/models/UserModelHelper";
export type CreditWithDetails = Credit & {
tenant: TenantSimple;
user: UserSimple | null;
};
export async function getAllCredits({
tenantId,
filters,
filterableProperties,
pagination,
}: {
tenantId: string | null;
filters: FiltersDto;
filterableProperties: FilterablePropertyDto[];
pagination: { pageSize: number; page: number };
}): Promise<{
items: CreditWithDetails[];
pagination: PaginationDto;
}> {
const q = filters.query || "";
const AND_filters: Prisma.CreditWhereInput[] = [];
filterableProperties.forEach((filter) => {
const value = filters.properties.find((f) => f.name === filter.name)?.value;
if (value) {
AND_filters.push({
[filter.name]: value === "null" ? null : value,
});
}
});
const OR_filters: Prisma.CreditWhereInput[] = [];
if (q) {
OR_filters.push({ type: { contains: q, mode: "insensitive" } }, { objectId: { contains: q, mode: "insensitive" } });
}
const whereFilters: Prisma.CreditWhereInput = {};
if (OR_filters.length > 0) {
whereFilters.OR = OR_filters;
}
if (AND_filters.length > 0) {
whereFilters.AND = AND_filters;
}
if (tenantId) {
whereFilters.tenantId = tenantId;
}
const items = await db.credit.findMany({
take: pagination.pageSize,
skip: pagination.pageSize * (pagination.page - 1),
where: whereFilters,
orderBy: {
createdAt: "desc",
},
include: {
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
user: { select: UserModelHelper.selectSimpleUserProperties },
},
});
const totalItems = await db.credit.count({
where: whereFilters,
});
return {
items,
pagination: {
page: pagination.page,
pageSize: pagination.pageSize,
totalItems,
totalPages: Math.ceil(totalItems / pagination.pageSize),
},
};
}
export async function createCredit(data: { tenantId: string; userId: string | null; type: string; objectId: string | null; amount: number }) {
return await db.credit.create({
data: {
tenantId: data.tenantId,
userId: data.userId,
type: data.type,
objectId: data.objectId,
amount: data.amount,
},
});
}
export async function getSpentCredits({
tenantId,
createdAt,
}: {
tenantId: string;
createdAt?: { gte?: Date; lt?: Date };
}) {
const result = await db.credit.aggregate({
where: {
tenantId,
amount: { lt: 0 },
...(createdAt ? { createdAt } : {}),
},
_sum: {
amount: true,
},
});
return Math.abs(result._sum.amount ?? 0);
}
export async function deleteCredits(ids: string[]) {
return await db.credit.deleteMany({
where: {
id: { in: ids },
},
});
}
@@ -0,0 +1,16 @@
export type CreditTypeDto = {
value: string;
name: string;
description: string;
amount: number;
};
export const CreditTypes: CreditTypeDto[] = [
{
name: "models.credit.types.aiGeneration.name",
value: "ai-generation",
description: "models.credit.types.aiGeneration.description",
amount: -1,
},
] as const;
export type CreditType = (typeof CreditTypes)[number]["value"];
@@ -0,0 +1,23 @@
import { createCredit } from "../db/credits.db.server";
import { CreditType, CreditTypes } from "../dtos/CreditType";
import { createScopedLogger } from "~/utils/logger.server";
const creditsLogger = createScopedLogger("usage.credits");
async function create(data: { tenantId: string; userId: string | null; type: CreditType; objectId: string | null }) {
const creditType = CreditTypes.find((t) => t.value === data.type);
if (!creditType) {
creditsLogger.error({ type: data.type }, `Credit type not found: ${data.type}`);
return;
}
return await createCredit({
tenantId: data.tenantId,
userId: data.userId,
type: data.type,
objectId: data.objectId,
amount: creditType.amount,
});
}
export default {
create,
};