cd78f72f33
Complete rename of all technical identifiers across the codebase: Package names (11 packages): - @planarchy/* → @capakraken/* in all package.json, tsconfig, imports Import statements: 277 files, 548 occurrences replaced Database & Docker: - PostgreSQL user/db: planarchy → capakraken - Docker volumes: planarchy_pgdata → capakraken_pgdata - Connection strings updated in docker-compose, .env, CI CI/CD: - GitHub Actions workflow: all filter commands updated - Test database credentials updated Infrastructure: - Redis channel: planarchy:sse → capakraken:sse - Logger service name: planarchy-api → capakraken-api - Anonymization seed updated - Start/stop/restart scripts updated Test data: - Seed emails: @planarchy.dev → @capakraken.dev - E2E test credentials: all 11 spec files updated - Email defaults: @planarchy.app → @capakraken.app - localStorage keys: planarchy_* → capakraken_* Documentation: 30+ .md files updated Verification: - pnpm install: workspace resolution works - TypeScript: only pre-existing TS2589 (no new errors) - Engine: 310/310 tests pass - Staffing: 37/37 tests pass Co-Authored-By: claude-flow <ruv@ruv.net>
228 lines
10 KiB
TypeScript
228 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
type EstimateExportArtifactPayload,
|
|
EstimateExportFormat,
|
|
} from "@capakraken/shared";
|
|
import type {
|
|
EstimateExportView,
|
|
EstimateVersionView,
|
|
EstimateWorkspaceView,
|
|
} from "~/components/estimates/EstimateWorkspace.types.js";
|
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
|
import { formatDateLong, formatMoney } from "~/lib/format.js";
|
|
|
|
const EXPORT_FORMATS: EstimateExportFormat[] = [
|
|
EstimateExportFormat.XLSX,
|
|
EstimateExportFormat.CSV,
|
|
EstimateExportFormat.JSON,
|
|
EstimateExportFormat.SAP,
|
|
EstimateExportFormat.MMP,
|
|
];
|
|
|
|
function formatBytes(value: number | null | undefined) {
|
|
const bytes = value ?? 0;
|
|
if (bytes < 1024) {
|
|
return `${bytes} B`;
|
|
}
|
|
if (bytes < 1024 * 1024) {
|
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
}
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
function isEstimateExportArtifactPayload(
|
|
payload: EstimateExportView["payload"],
|
|
): payload is EstimateExportArtifactPayload {
|
|
return (
|
|
typeof payload === "object" &&
|
|
payload !== null &&
|
|
typeof payload.content === "string" &&
|
|
typeof payload.mimeType === "string" &&
|
|
typeof payload.encoding === "string" &&
|
|
typeof payload.fileExtension === "string" &&
|
|
typeof payload.generatedAt === "string" &&
|
|
typeof payload.byteLength === "number" &&
|
|
typeof payload.summary === "object" &&
|
|
payload.summary !== null
|
|
);
|
|
}
|
|
|
|
function decodeBase64(value: string) {
|
|
const binary = atob(value);
|
|
const bytes = new Uint8Array(binary.length);
|
|
|
|
for (let index = 0; index < binary.length; index += 1) {
|
|
bytes[index] = binary.charCodeAt(index);
|
|
}
|
|
|
|
return bytes;
|
|
}
|
|
|
|
function downloadEstimateExport(estimateExport: EstimateExportView) {
|
|
const payload = estimateExport.payload;
|
|
if (!isEstimateExportArtifactPayload(payload)) {
|
|
return;
|
|
}
|
|
|
|
const blob =
|
|
payload.encoding === "base64"
|
|
? new Blob([decodeBase64(payload.content)], { type: payload.mimeType })
|
|
: new Blob([payload.content], { type: payload.mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = estimateExport.fileName;
|
|
document.body.append(link);
|
|
link.click();
|
|
link.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export interface ExportsTabProps {
|
|
estimate: EstimateWorkspaceView;
|
|
canEdit: boolean;
|
|
onCreateExport: (versionId: string, format: EstimateExportFormat) => void;
|
|
isCreatingExport: boolean;
|
|
}
|
|
|
|
export function ExportsTab({
|
|
estimate,
|
|
canEdit,
|
|
onCreateExport,
|
|
isCreatingExport,
|
|
}: ExportsTabProps) {
|
|
const versions = estimate.versions as EstimateVersionView[];
|
|
const latestVersion = versions[0] ?? null;
|
|
const exports = latestVersion?.exports ?? [];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Export delivery <InfoTooltip content="Generate downloadable files from the current estimate version. Each format includes demand lines, scope, and financial summaries." /></h2>
|
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
Generate format-specific artifacts from the current version and download them directly from the stored serializer payload.
|
|
</p>
|
|
</div>
|
|
{latestVersion && canEdit && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{EXPORT_FORMATS.map((format) => (
|
|
<button
|
|
key={format}
|
|
type="button"
|
|
onClick={() => onCreateExport(latestVersion.id, format)}
|
|
disabled={isCreatingExport}
|
|
className="rounded-2xl border border-brand-200 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{isCreatingExport ? "Generating..." : `Create ${format}`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm">
|
|
<div className="border-b border-gray-100 dark:border-gray-700/50 px-6 py-4">
|
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Generated exports <InfoTooltip content="Previously generated export artifacts. XLSX/CSV contain tabular data; JSON is machine-readable; SAP/MMP are ERP integration formats." /></h2>
|
|
</div>
|
|
{exports.length === 0 ? (
|
|
<div className="px-6 py-8">
|
|
<p className="text-sm text-gray-400">No exports have been generated for the current version yet.</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-100 dark:divide-gray-700/50">
|
|
{exports.map((estimateExport) => {
|
|
const payload = isEstimateExportArtifactPayload(estimateExport.payload)
|
|
? estimateExport.payload
|
|
: null;
|
|
|
|
return (
|
|
<div key={estimateExport.id} className="px-6 py-5">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{estimateExport.fileName}</p>
|
|
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
|
|
{estimateExport.format}
|
|
</span>
|
|
{payload?.sheetNames?.length ? (
|
|
<span className="rounded-full bg-sky-50 dark:bg-sky-900/30 px-2.5 py-1 text-[11px] font-semibold text-sky-700 dark:text-sky-300">
|
|
{payload.sheetNames.length} sheets
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
|
|
<span>{formatDateLong(estimateExport.createdAt)}</span>
|
|
{payload ? <span>{formatBytes(payload.byteLength)}</span> : null}
|
|
{payload?.rowCount != null ? <span>{payload.rowCount} rows</span> : null}
|
|
{payload?.lineCount != null ? <span>{payload.lineCount} lines</span> : null}
|
|
</div>
|
|
{payload ? (
|
|
<div className="mt-3 grid gap-2 text-xs text-gray-600 dark:text-gray-400 md:grid-cols-2 xl:grid-cols-4">
|
|
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
|
|
<p className="uppercase tracking-wide text-gray-400">Hours</p>
|
|
<p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
|
|
{payload.summary.totalHours.toFixed(1)}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
|
|
<p className="uppercase tracking-wide text-gray-400">Cost</p>
|
|
<p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
|
|
{formatMoney(
|
|
payload.summary.totalCostCents,
|
|
payload.summary.baseCurrency,
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
|
|
<p className="uppercase tracking-wide text-gray-400">Price</p>
|
|
<p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
|
|
{formatMoney(
|
|
payload.summary.totalPriceCents,
|
|
payload.summary.baseCurrency,
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
|
|
<p className="uppercase tracking-wide text-gray-400">Margin</p>
|
|
<p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
|
|
{payload.summary.marginPercent.toFixed(0)}%
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="mt-3 text-xs text-amber-700 dark:text-amber-300">
|
|
Legacy export record detected. Regenerate it to get downloadable serializer output.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{payload ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => downloadEstimateExport(estimateExport)}
|
|
className="rounded-2xl border border-brand-200 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50"
|
|
>
|
|
Download
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
{payload?.previewText ? (
|
|
<pre className="mt-4 overflow-x-auto rounded-2xl bg-gray-950/95 p-4 text-xs text-gray-100">
|
|
{payload.previewText}
|
|
</pre>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|