feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -7,7 +7,7 @@ import { clsx } from "clsx";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type EntityType = "resource" | "project" | "assignment";
|
||||
type EntityType = "resource" | "project" | "assignment" | "resource_month";
|
||||
type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
|
||||
|
||||
interface FilterRow {
|
||||
@@ -17,10 +17,50 @@ interface FilterRow {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface AvailableColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
dataType: "string" | "number" | "date" | "boolean";
|
||||
}
|
||||
|
||||
interface TemplateConfig {
|
||||
entity: EntityType;
|
||||
columns: string[];
|
||||
filters: Omit<FilterRow, "id">[];
|
||||
groupBy?: string;
|
||||
sortBy?: string;
|
||||
sortDir?: "asc" | "desc";
|
||||
periodMonth?: string;
|
||||
}
|
||||
|
||||
interface ReportTemplateSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
entity: EntityType;
|
||||
config: TemplateConfig;
|
||||
isShared: boolean;
|
||||
isOwner: boolean;
|
||||
updatedAt: string | Date;
|
||||
}
|
||||
|
||||
interface ReportBlueprint {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
entity: EntityType;
|
||||
columns: string[];
|
||||
groupBy?: string;
|
||||
sortBy?: string;
|
||||
sortDir?: "asc" | "desc";
|
||||
templateName: string;
|
||||
}
|
||||
|
||||
const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
|
||||
{ value: "resource", label: "Resources" },
|
||||
{ value: "project", label: "Projects" },
|
||||
{ value: "assignment", label: "Assignments" },
|
||||
{ value: "resource_month", label: "Resource Months" },
|
||||
];
|
||||
|
||||
const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
|
||||
@@ -36,10 +76,120 @@ const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
|
||||
"displayName",
|
||||
"eid",
|
||||
"chapter",
|
||||
"countryCode",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"monthlyBaseWorkingDays",
|
||||
"monthlyEffectiveWorkingDays",
|
||||
"monthlyBaseAvailableHours",
|
||||
"monthlyPublicHolidayWorkdayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceDayEquivalent",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyChargeabilityTargetPct",
|
||||
"monthlyTargetHours",
|
||||
"monthlyActualBookedHours",
|
||||
"monthlyExpectedBookedHours",
|
||||
"monthlyActualChargeabilityPct",
|
||||
"monthlyExpectedChargeabilityPct",
|
||||
"monthlyUnassignedHours",
|
||||
] as const;
|
||||
|
||||
const REPORT_BLUEPRINTS: ReportBlueprint[] = [
|
||||
{
|
||||
id: "resource-month-sah-transparency",
|
||||
label: "SAH transparency",
|
||||
description: "Explains how monthly SAH is reduced by holidays and absences per person.",
|
||||
entity: "resource_month",
|
||||
templateName: "Monthly SAH transparency",
|
||||
columns: [
|
||||
"displayName",
|
||||
"eid",
|
||||
"chapter",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"monthlyBaseWorkingDays",
|
||||
"monthlyEffectiveWorkingDays",
|
||||
"monthlyBaseAvailableHours",
|
||||
"monthlyPublicHolidayWorkdayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceDayEquivalent",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyChargeabilityTargetPct",
|
||||
"monthlyTargetHours",
|
||||
],
|
||||
sortBy: "displayName",
|
||||
sortDir: "asc",
|
||||
},
|
||||
{
|
||||
id: "resource-month-chargeability-audit",
|
||||
label: "Chargeability audit",
|
||||
description: "Shows the full path from monthly SAH to booked, target and unassigned hours.",
|
||||
entity: "resource_month",
|
||||
templateName: "Monthly chargeability audit",
|
||||
columns: [
|
||||
"displayName",
|
||||
"eid",
|
||||
"chapter",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"monthlySahHours",
|
||||
"monthlyChargeabilityTargetPct",
|
||||
"monthlyTargetHours",
|
||||
"monthlyActualBookedHours",
|
||||
"monthlyExpectedBookedHours",
|
||||
"monthlyActualChargeabilityPct",
|
||||
"monthlyExpectedChargeabilityPct",
|
||||
"monthlyUnassignedHours",
|
||||
"lcrCents",
|
||||
"currency",
|
||||
],
|
||||
sortBy: "monthlyActualChargeabilityPct",
|
||||
sortDir: "desc",
|
||||
},
|
||||
{
|
||||
id: "resource-month-location-comparison",
|
||||
label: "Location comparison",
|
||||
description: "Compares holiday impact across country, state and city contexts for the same month.",
|
||||
entity: "resource_month",
|
||||
templateName: "Monthly holiday comparison by location",
|
||||
columns: [
|
||||
"displayName",
|
||||
"chapter",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"monthlyBaseWorkingDays",
|
||||
"monthlyPublicHolidayWorkdayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyActualChargeabilityPct",
|
||||
],
|
||||
groupBy: "federalState",
|
||||
sortBy: "monthlyPublicHolidayHoursDeduction",
|
||||
sortDir: "desc",
|
||||
},
|
||||
];
|
||||
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
function getCurrentPeriodMonth(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function ReportBuilder() {
|
||||
@@ -50,6 +200,9 @@ export function ReportBuilder() {
|
||||
const [groupBy, setGroupBy] = useState<string>("");
|
||||
const [sortBy, setSortBy] = useState<string>("");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
const [periodMonth, setPeriodMonth] = useState<string>(getCurrentPeriodMonth());
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||||
const [templateName, setTemplateName] = useState<string>("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [runQuery, setRunQuery] = useState(false);
|
||||
|
||||
@@ -59,7 +212,21 @@ export function ReportBuilder() {
|
||||
{ placeholderData: keepPreviousData },
|
||||
);
|
||||
|
||||
const availableColumns = columnsQuery.data ?? [];
|
||||
const availableColumns: AvailableColumn[] = columnsQuery.data ?? [];
|
||||
const templatesQuery = trpc.report.listTemplates.useQuery();
|
||||
const saveTemplateMutation = trpc.report.saveTemplate.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
setSelectedTemplateId(result.id);
|
||||
await templatesQuery.refetch();
|
||||
},
|
||||
});
|
||||
const deleteTemplateMutation = trpc.report.deleteTemplate.useMutation({
|
||||
onSuccess: async () => {
|
||||
setSelectedTemplateId("");
|
||||
setTemplateName("");
|
||||
await templatesQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
// Scalar columns (for filter/sort/group — only non-relation columns)
|
||||
const scalarColumns = useMemo(
|
||||
@@ -76,12 +243,13 @@ export function ReportBuilder() {
|
||||
filters: filters
|
||||
.filter((f) => f.field && f.value)
|
||||
.map(({ field, op, value }) => ({ field, op, value })),
|
||||
...(entity === "resource_month" ? { periodMonth } : {}),
|
||||
...(groupBy ? { groupBy } : {}),
|
||||
...(sortBy ? { sortBy, sortDir } : {}),
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
};
|
||||
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page]);
|
||||
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page, periodMonth]);
|
||||
|
||||
// Fetch report data
|
||||
const reportQuery = trpc.report.getReportData.useQuery(
|
||||
@@ -99,6 +267,40 @@ export function ReportBuilder() {
|
||||
setFilters([]);
|
||||
setGroupBy("");
|
||||
setSortBy("");
|
||||
if (newEntity === "resource_month") {
|
||||
setPeriodMonth((current) => current || getCurrentPeriodMonth());
|
||||
}
|
||||
setRunQuery(false);
|
||||
setPage(0);
|
||||
}, []);
|
||||
|
||||
const applyTemplate = useCallback((template: ReportTemplateSummary) => {
|
||||
const config = template.config;
|
||||
setSelectedTemplateId(template.id);
|
||||
setTemplateName(template.name);
|
||||
setEntity(config.entity);
|
||||
setSelectedColumns(new Set(config.columns));
|
||||
setFilters(config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })));
|
||||
setGroupBy(config.groupBy ?? "");
|
||||
setSortBy(config.sortBy ?? "");
|
||||
setSortDir(config.sortDir ?? "asc");
|
||||
setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
|
||||
setRunQuery(false);
|
||||
setPage(0);
|
||||
}, [templatesQuery.data]);
|
||||
|
||||
const applyBlueprint = useCallback((blueprint: ReportBlueprint) => {
|
||||
setSelectedTemplateId("");
|
||||
setTemplateName(blueprint.templateName);
|
||||
setEntity(blueprint.entity);
|
||||
setSelectedColumns(new Set(blueprint.columns));
|
||||
setFilters([]);
|
||||
setGroupBy(blueprint.groupBy ?? "");
|
||||
setSortBy(blueprint.sortBy ?? "");
|
||||
setSortDir(blueprint.sortDir ?? "asc");
|
||||
if (blueprint.entity === "resource_month") {
|
||||
setPeriodMonth((current) => current || getCurrentPeriodMonth());
|
||||
}
|
||||
setRunQuery(false);
|
||||
setPage(0);
|
||||
}, []);
|
||||
@@ -163,6 +365,7 @@ export function ReportBuilder() {
|
||||
filters: filters
|
||||
.filter((f) => f.field && f.value)
|
||||
.map(({ field, op, value }) => ({ field, op, value })),
|
||||
...(entity === "resource_month" ? { periodMonth } : {}),
|
||||
...(groupBy ? { groupBy } : {}),
|
||||
...(sortBy ? { sortBy, sortDir } : {}),
|
||||
limit: 5000,
|
||||
@@ -179,7 +382,42 @@ export function ReportBuilder() {
|
||||
} catch {
|
||||
// Error handled by tRPC
|
||||
}
|
||||
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation]);
|
||||
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation, periodMonth]);
|
||||
|
||||
const handleSaveTemplate = useCallback(async () => {
|
||||
if (!templateName.trim() || selectedColumns.size === 0) return;
|
||||
|
||||
await saveTemplateMutation.mutateAsync({
|
||||
...(selectedTemplateId ? { id: selectedTemplateId } : {}),
|
||||
name: templateName.trim(),
|
||||
config: {
|
||||
entity,
|
||||
columns: Array.from(selectedColumns),
|
||||
filters: filters
|
||||
.filter((filter) => filter.field && filter.value)
|
||||
.map(({ field, op, value }) => ({ field, op, value })),
|
||||
...(entity === "resource_month" ? { periodMonth } : {}),
|
||||
...(groupBy ? { groupBy } : {}),
|
||||
...(sortBy ? { sortBy, sortDir } : {}),
|
||||
},
|
||||
});
|
||||
}, [
|
||||
entity,
|
||||
filters,
|
||||
groupBy,
|
||||
periodMonth,
|
||||
saveTemplateMutation,
|
||||
selectedColumns,
|
||||
selectedTemplateId,
|
||||
sortBy,
|
||||
sortDir,
|
||||
templateName,
|
||||
]);
|
||||
|
||||
const handleDeleteTemplate = useCallback(async () => {
|
||||
if (!selectedTemplateId) return;
|
||||
await deleteTemplateMutation.mutateAsync({ id: selectedTemplateId });
|
||||
}, [deleteTemplateMutation, selectedTemplateId]);
|
||||
|
||||
// ─── Derived ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -188,6 +426,15 @@ export function ReportBuilder() {
|
||||
const outputColumns = reportQuery.data?.columns ?? [];
|
||||
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
||||
const isLoading = reportQuery.isFetching;
|
||||
const templates = templatesQuery.data ?? [];
|
||||
const resourceMonthBlueprints = useMemo(
|
||||
() => REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === entity),
|
||||
[entity],
|
||||
);
|
||||
const recommendedColumnSet = useMemo(
|
||||
() => entity === "resource_month" ? new Set<string>(RESOURCE_MONTH_RECOMMENDED_COLUMNS) : new Set<string>(),
|
||||
[entity],
|
||||
);
|
||||
|
||||
// Column label lookup
|
||||
const columnLabelMap = useMemo(() => {
|
||||
@@ -212,6 +459,61 @@ export function ReportBuilder() {
|
||||
|
||||
{/* Config Panel */}
|
||||
<div className="space-y-5 rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-gray-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/60 lg:grid-cols-[minmax(0,1fr)_220px_auto_auto]">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Template
|
||||
</label>
|
||||
<select
|
||||
value={selectedTemplateId}
|
||||
onChange={(e) => {
|
||||
const nextId = e.target.value;
|
||||
setSelectedTemplateId(nextId);
|
||||
const template = templates.find((entry: ReportTemplateSummary) => entry.id === nextId);
|
||||
if (template) {
|
||||
applyTemplate(template);
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
|
||||
>
|
||||
<option value="">Unsaved view</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}{template.isShared && !template.isOwner ? " · shared" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Template name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
placeholder="Monthly SAH by location"
|
||||
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSaveTemplate()}
|
||||
disabled={!templateName.trim() || selectedColumns.size === 0 || saveTemplateMutation.isPending}
|
||||
className="self-end rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saveTemplateMutation.isPending ? "Saving..." : selectedTemplateId ? "Update template" : "Save template"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteTemplate()}
|
||||
disabled={!selectedTemplateId || deleteTemplateMutation.isPending}
|
||||
className="self-end rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300 dark:hover:bg-slate-900"
|
||||
>
|
||||
{deleteTemplateMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Entity Selector */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
@@ -234,6 +536,73 @@ export function ReportBuilder() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{entity === "resource_month" && (
|
||||
<div className="mt-4 space-y-4 rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/20">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-emerald-900 dark:text-emerald-200">
|
||||
Period month
|
||||
</label>
|
||||
<input
|
||||
type="month"
|
||||
value={periodMonth}
|
||||
onChange={(e) => setPeriodMonth(e.target.value)}
|
||||
className="rounded-xl border border-emerald-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-emerald-500 focus:ring-emerald-500 dark:border-emerald-900 dark:bg-slate-950 dark:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<p className="max-w-2xl text-sm text-emerald-900/80 dark:text-emerald-200/80">
|
||||
Resource Months uses the CapaKraken holiday and absence logic directly. SAH, booked hours and chargeability are calculated per resource and month with country, state and city context.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
{resourceMonthBlueprints.map((blueprint) => (
|
||||
<button
|
||||
key={blueprint.id}
|
||||
type="button"
|
||||
onClick={() => applyBlueprint(blueprint)}
|
||||
className="rounded-2xl border border-emerald-200 bg-white/80 p-4 text-left transition hover:border-emerald-400 hover:bg-white dark:border-emerald-900/70 dark:bg-slate-950/60 dark:hover:border-emerald-700"
|
||||
>
|
||||
<div className="text-sm font-semibold text-emerald-950 dark:text-emerald-100">
|
||||
{blueprint.label}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-emerald-900/75 dark:text-emerald-200/75">
|
||||
{blueprint.description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-emerald-200/80 bg-white/60 p-4 dark:border-emerald-900/60 dark:bg-slate-950/40">
|
||||
<div className="text-sm font-medium text-emerald-950 dark:text-emerald-100">
|
||||
Recommended transparency columns
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{RESOURCE_MONTH_RECOMMENDED_COLUMNS.map((column) => (
|
||||
<button
|
||||
key={column}
|
||||
type="button"
|
||||
onClick={() => toggleColumn(column)}
|
||||
className={clsx(
|
||||
"rounded-full border px-3 py-1 text-xs font-medium transition",
|
||||
selectedColumns.has(column)
|
||||
? "border-emerald-500 bg-emerald-500 text-white"
|
||||
: "border-emerald-200 bg-white text-emerald-900 hover:border-emerald-400 dark:border-emerald-900 dark:bg-slate-950 dark:text-emerald-200 dark:hover:border-emerald-700",
|
||||
)}
|
||||
>
|
||||
{columnLabelMap.get(column) ?? column}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-emerald-900/75 dark:text-emerald-200/75">
|
||||
Formula reference: base available hours - holiday deduction - absence deduction = monthly SAH. Chargeability uses booked hours divided by monthly SAH.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
|
||||
Export recommendation: include both basis columns and computed metrics in the CSV. That keeps Excel as a review layer instead of rebuilding CapaKraken logic outside the product.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column Picker */}
|
||||
@@ -276,6 +645,11 @@ export function ReportBuilder() {
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">{col.label}</span>
|
||||
{recommendedColumnSet.has(col.key) && (
|
||||
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.14em] text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300">
|
||||
Rec
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-600">
|
||||
{col.dataType}
|
||||
</span>
|
||||
@@ -428,13 +802,18 @@ export function ReportBuilder() {
|
||||
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
||||
{/* Results Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-slate-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
|
||||
{!isLoading && (
|
||||
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400">
|
||||
{totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
|
||||
{!isLoading && (
|
||||
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400">
|
||||
{totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user