@@ -285,7 +308,12 @@ export default function ServicesPage() {
}
/>
)}
- {!loading && services && services.length > 0 && (
+ {!loading && !error && services && services.length > 0 && sortedServices?.length === 0 && (
+
+ No services match this filter.
+
+ )}
+ {!loading && services && sortedServices && sortedServices.length > 0 && (
{sortedServices?.map((s) => (
- {
+ it("exports only the active filtered view and preserves its display order", () => {
+ const filtered = filterServicesForView(rows, "SVC-A");
+
+ expect(filtered).toEqual([rows[1]]);
+ expect(JSON.parse(serializeServicesJson(filtered))).toEqual([rows[1]]);
+ });
+
+ it("quotes commas, quotes and newlines in CSV cells", () => {
+ const csv = serializeServicesCsv([
+ {
+ serviceId: 'svc,"quoted"\nline',
+ priceStroops: 10,
+ createdAt: null,
+ },
+ ]);
+
+ expect(csv).toContain('"svc,""quoted""\nline"');
+ });
+
+ it.each([
+ "=2+3",
+ "+2+3",
+ "-2+3",
+ "@SUM(A1:A2)",
+ " =2+3",
+ "\t+2+3",
+ "\r-2+3",
+ "\n@SUM(A1:A2)",
+ ])(
+ "neutralizes spreadsheet formula trigger %j",
+ (value) => {
+ expect(escapeCsvCell(value)).toBe(`"'${value}"`);
+ },
+ );
+
+ it("produces a valid empty CSV export", () => {
+ expect(serializeServicesCsv([])).toBe(
+ "serviceId,priceStroops,createdAt\r\n",
+ );
+ });
+
+ it("round-trips JSON data", () => {
+ expect(JSON.parse(serializeServicesJson(rows))).toEqual(rows);
+ });
+
+ it("builds predictable client download metadata", () => {
+ expect(buildServiceExport(rows, "csv")).toMatchObject({
+ filename: "services-current-view.csv",
+ mimeType: "text/csv;charset=utf-8",
+ });
+ expect(buildServiceExport(rows, "json")).toMatchObject({
+ filename: "services-current-view.json",
+ mimeType: "application/json;charset=utf-8",
+ });
+ });
+});
diff --git a/src/app/services/serviceExport.ts b/src/app/services/serviceExport.ts
new file mode 100644
index 0000000..6a9f50b
--- /dev/null
+++ b/src/app/services/serviceExport.ts
@@ -0,0 +1,101 @@
+export type ServiceExportRow = Readonly<{
+ serviceId: string;
+ priceStroops: number;
+ createdAt?: number | string | null;
+}>;
+
+export type ServiceExportFormat = "csv" | "json";
+
+export type ServiceExportErrorCode =
+ | "EXPORT_SERIALIZATION_FAILED"
+ | "EXPORT_DOWNLOAD_UNAVAILABLE";
+
+export class ServiceExportError extends Error {
+ readonly code: ServiceExportErrorCode;
+
+ constructor(code: ServiceExportErrorCode, message: string) {
+ super(message);
+ this.name = "ServiceExportError";
+ this.code = code;
+ }
+}
+
+export function filterServicesForView(
+ services: readonly T[],
+ query: string,
+): T[] {
+ const normalizedQuery = query.trim().toLowerCase();
+ if (!normalizedQuery) return services.slice();
+
+ return services.filter((service) =>
+ service.serviceId.toLowerCase().includes(normalizedQuery),
+ );
+}
+
+const CSV_HEADER = "serviceId,priceStroops,createdAt";
+
+const SPREADSHEET_FORMULA_PREFIX = /^[\t\r\n ]*[=+\-@]/;
+
+export function escapeCsvCell(value: string): string {
+ // Spreadsheet importers may ignore leading whitespace/control characters
+ // before deciding that a cell is a formula. Prefix the original value so it
+ // stays inert without trimming or otherwise changing user data.
+ const neutralized = SPREADSHEET_FORMULA_PREFIX.test(value)
+ ? `'${value}`
+ : value;
+ return `"${neutralized.replace(/"/g, '""')}"`;
+}
+
+export function serializeServicesCsv(
+ services: readonly ServiceExportRow[],
+): string {
+ const rows = services.map((service) =>
+ [
+ service.serviceId,
+ String(service.priceStroops),
+ service.createdAt == null ? "" : String(service.createdAt),
+ ]
+ .map(escapeCsvCell)
+ .join(","),
+ );
+
+ return [CSV_HEADER, ...rows].join("\r\n") + "\r\n";
+}
+
+export function serializeServicesJson(
+ services: readonly ServiceExportRow[],
+): string {
+ return `${JSON.stringify(services, null, 2)}\n`;
+}
+
+export type ServiceExportPayload = Readonly<{
+ content: string;
+ mimeType: string;
+ filename: string;
+}>;
+
+export function buildServiceExport(
+ services: readonly ServiceExportRow[],
+ format: ServiceExportFormat,
+): ServiceExportPayload {
+ try {
+ if (format === "csv") {
+ return {
+ content: serializeServicesCsv(services),
+ mimeType: "text/csv;charset=utf-8",
+ filename: "services-current-view.csv",
+ };
+ }
+
+ return {
+ content: serializeServicesJson(services),
+ mimeType: "application/json;charset=utf-8",
+ filename: "services-current-view.json",
+ };
+ } catch {
+ throw new ServiceExportError(
+ "EXPORT_SERIALIZATION_FAILED",
+ "Could not prepare the current services view for export.",
+ );
+ }
+}