@open-mercato/core 0.4.6-develop-db293c4bbe → 0.4.6-develop-a88276bc52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/customers/api/todos/route.js +180 -0
- package/dist/modules/customers/api/todos/route.js.map +7 -0
- package/dist/modules/customers/components/CustomerTodosTable.js +57 -89
- package/dist/modules/customers/components/CustomerTodosTable.js.map +2 -2
- package/package.json +2 -2
- package/src/modules/customers/api/todos/route.ts +209 -0
- package/src/modules/customers/components/CustomerTodosTable.tsx +15 -59
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
3
|
+
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
4
|
+
import { CustomerTodoLink } from "../../data/entities.js";
|
|
5
|
+
import { createCustomersCrudOpenApi, createPagedListResponseSchema } from "../openapi.js";
|
|
6
|
+
import { decryptEntitiesWithFallbackScope } from "@open-mercato/shared/lib/encryption/subscriber";
|
|
7
|
+
import { parseBooleanToken } from "@open-mercato/shared/lib/boolean";
|
|
8
|
+
const querySchema = z.object({
|
|
9
|
+
page: z.coerce.number().min(1).default(1),
|
|
10
|
+
pageSize: z.coerce.number().min(1).max(100).default(50),
|
|
11
|
+
search: z.string().optional(),
|
|
12
|
+
all: z.string().optional()
|
|
13
|
+
});
|
|
14
|
+
const metadata = {
|
|
15
|
+
GET: { requireAuth: true, requireFeatures: ["customers.view"] }
|
|
16
|
+
};
|
|
17
|
+
const TITLE_FIELDS = ["title", "subject", "name", "summary", "text"];
|
|
18
|
+
const IS_DONE_FIELDS = ["is_done", "isDone", "done", "completed"];
|
|
19
|
+
function resolveTodoTitle(raw) {
|
|
20
|
+
for (const key of TITLE_FIELDS) {
|
|
21
|
+
const value = raw[key];
|
|
22
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
function resolveTodoIsDone(raw) {
|
|
27
|
+
for (const key of IS_DONE_FIELDS) {
|
|
28
|
+
const value = raw[key];
|
|
29
|
+
if (typeof value === "boolean") return value;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
async function resolveTodoSummaries(queryEngine, links, tenantId, orgId) {
|
|
34
|
+
const results = /* @__PURE__ */ new Map();
|
|
35
|
+
if (!links.length || !tenantId) return results;
|
|
36
|
+
const idsBySource = /* @__PURE__ */ new Map();
|
|
37
|
+
for (const link of links) {
|
|
38
|
+
if (!link.todoSource || !link.todoId) continue;
|
|
39
|
+
if (!idsBySource.has(link.todoSource)) idsBySource.set(link.todoSource, /* @__PURE__ */ new Set());
|
|
40
|
+
idsBySource.get(link.todoSource).add(link.todoId);
|
|
41
|
+
}
|
|
42
|
+
const requestedFields = ["id", ...TITLE_FIELDS, ...IS_DONE_FIELDS];
|
|
43
|
+
const organizationIds = orgId ? [orgId] : void 0;
|
|
44
|
+
for (const [source, idSet] of idsBySource.entries()) {
|
|
45
|
+
const ids = Array.from(idSet);
|
|
46
|
+
try {
|
|
47
|
+
const result = await queryEngine.query(source, {
|
|
48
|
+
tenantId,
|
|
49
|
+
organizationIds,
|
|
50
|
+
filters: { id: { $in: ids } },
|
|
51
|
+
fields: requestedFields,
|
|
52
|
+
includeCustomFields: false,
|
|
53
|
+
page: { page: 1, pageSize: Math.max(ids.length, 1) }
|
|
54
|
+
});
|
|
55
|
+
for (const item of result.items ?? []) {
|
|
56
|
+
const raw = item;
|
|
57
|
+
const todoId = typeof raw.id === "string" ? raw.id : String(raw.id ?? "");
|
|
58
|
+
if (!todoId) continue;
|
|
59
|
+
results.set(`${source}:${todoId}`, {
|
|
60
|
+
title: resolveTodoTitle(raw),
|
|
61
|
+
isDone: resolveTodoIsDone(raw)
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return results;
|
|
68
|
+
}
|
|
69
|
+
async function GET(request) {
|
|
70
|
+
const auth = await getAuthFromRequest(request);
|
|
71
|
+
if (!auth?.sub && !auth?.isApiKey) {
|
|
72
|
+
return new Response(JSON.stringify({ error: "Authentication required" }), {
|
|
73
|
+
status: 401,
|
|
74
|
+
headers: { "Content-Type": "application/json" }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const url = new URL(request.url);
|
|
78
|
+
const parsed = querySchema.safeParse({
|
|
79
|
+
page: url.searchParams.get("page") ?? void 0,
|
|
80
|
+
pageSize: url.searchParams.get("pageSize") ?? void 0,
|
|
81
|
+
search: url.searchParams.get("search") ?? void 0,
|
|
82
|
+
all: url.searchParams.get("all") ?? void 0
|
|
83
|
+
});
|
|
84
|
+
if (!parsed.success) {
|
|
85
|
+
return new Response(JSON.stringify({ error: "Invalid parameters" }), {
|
|
86
|
+
status: 400,
|
|
87
|
+
headers: { "Content-Type": "application/json" }
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const { page, pageSize, search, all } = parsed.data;
|
|
91
|
+
const exportAll = parseBooleanToken(all);
|
|
92
|
+
const container = await createRequestContainer();
|
|
93
|
+
const em = container.resolve("em");
|
|
94
|
+
const where = {
|
|
95
|
+
tenantId: auth.tenantId
|
|
96
|
+
};
|
|
97
|
+
if (auth.orgId) {
|
|
98
|
+
where.organizationId = auth.orgId;
|
|
99
|
+
}
|
|
100
|
+
if (search?.trim()) {
|
|
101
|
+
where.entity = { displayName: { $ilike: `%${search.trim()}%` } };
|
|
102
|
+
}
|
|
103
|
+
const [links, total] = await em.findAndCount(
|
|
104
|
+
CustomerTodoLink,
|
|
105
|
+
where,
|
|
106
|
+
{
|
|
107
|
+
populate: ["entity"],
|
|
108
|
+
orderBy: { createdAt: "desc" },
|
|
109
|
+
...exportAll ? {} : {
|
|
110
|
+
offset: (page - 1) * pageSize,
|
|
111
|
+
limit: pageSize
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
await decryptEntitiesWithFallbackScope(links, {
|
|
116
|
+
em,
|
|
117
|
+
tenantId: auth.tenantId,
|
|
118
|
+
organizationId: auth.orgId ?? null
|
|
119
|
+
});
|
|
120
|
+
const queryEngine = container.resolve("queryEngine");
|
|
121
|
+
const todoSummaries = await resolveTodoSummaries(queryEngine, links, auth.tenantId, auth.orgId ?? null);
|
|
122
|
+
const effectivePage = exportAll ? 1 : page;
|
|
123
|
+
const effectivePageSize = exportAll ? total : pageSize;
|
|
124
|
+
const items = links.map((link) => {
|
|
125
|
+
const summary = todoSummaries.get(`${link.todoSource}:${link.todoId}`) ?? null;
|
|
126
|
+
return {
|
|
127
|
+
id: link.id,
|
|
128
|
+
todoId: link.todoId,
|
|
129
|
+
todoSource: link.todoSource,
|
|
130
|
+
todoTitle: summary?.title ?? null,
|
|
131
|
+
todoIsDone: summary?.isDone ?? null,
|
|
132
|
+
todoOrganizationId: link.organizationId,
|
|
133
|
+
organizationId: link.organizationId,
|
|
134
|
+
tenantId: link.tenantId,
|
|
135
|
+
createdAt: link.createdAt.toISOString(),
|
|
136
|
+
customer: {
|
|
137
|
+
id: link.entity.id,
|
|
138
|
+
displayName: link.entity.displayName,
|
|
139
|
+
kind: link.entity.kind
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
return new Response(
|
|
144
|
+
JSON.stringify({
|
|
145
|
+
items,
|
|
146
|
+
total,
|
|
147
|
+
page: effectivePage,
|
|
148
|
+
pageSize: effectivePageSize,
|
|
149
|
+
totalPages: exportAll ? 1 : Math.ceil(total / pageSize)
|
|
150
|
+
}),
|
|
151
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const todoItemSchema = z.object({
|
|
155
|
+
id: z.string(),
|
|
156
|
+
todoId: z.string(),
|
|
157
|
+
todoSource: z.string(),
|
|
158
|
+
todoTitle: z.string().nullable(),
|
|
159
|
+
todoIsDone: z.boolean().nullable(),
|
|
160
|
+
todoOrganizationId: z.string().nullable(),
|
|
161
|
+
organizationId: z.string(),
|
|
162
|
+
tenantId: z.string(),
|
|
163
|
+
createdAt: z.string(),
|
|
164
|
+
customer: z.object({
|
|
165
|
+
id: z.string().nullable(),
|
|
166
|
+
displayName: z.string().nullable(),
|
|
167
|
+
kind: z.string().nullable()
|
|
168
|
+
})
|
|
169
|
+
});
|
|
170
|
+
const openApi = createCustomersCrudOpenApi({
|
|
171
|
+
resourceName: "CustomerTodo",
|
|
172
|
+
querySchema,
|
|
173
|
+
listResponseSchema: createPagedListResponseSchema(todoItemSchema)
|
|
174
|
+
});
|
|
175
|
+
export {
|
|
176
|
+
GET,
|
|
177
|
+
metadata,
|
|
178
|
+
openApi
|
|
179
|
+
};
|
|
180
|
+
//# sourceMappingURL=route.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../src/modules/customers/api/todos/route.ts"],
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { CustomerTodoLink } from '../../data/entities'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createCustomersCrudOpenApi, createPagedListResponseSchema } from '../openapi'\nimport { decryptEntitiesWithFallbackScope } from '@open-mercato/shared/lib/encryption/subscriber'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\n\nconst querySchema = z.object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n search: z.string().optional(),\n all: z.string().optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.view'] },\n}\n\nconst TITLE_FIELDS = ['title', 'subject', 'name', 'summary', 'text'] as const\nconst IS_DONE_FIELDS = ['is_done', 'isDone', 'done', 'completed'] as const\n\nfunction resolveTodoTitle(raw: Record<string, unknown>): string | null {\n for (const key of TITLE_FIELDS) {\n const value = raw[key]\n if (typeof value === 'string' && value.trim()) return value.trim()\n }\n return null\n}\n\nfunction resolveTodoIsDone(raw: Record<string, unknown>): boolean | null {\n for (const key of IS_DONE_FIELDS) {\n const value = raw[key]\n if (typeof value === 'boolean') return value\n }\n return null\n}\n\nasync function resolveTodoSummaries(\n queryEngine: QueryEngine,\n links: CustomerTodoLink[],\n tenantId: string | null,\n orgId: string | null,\n): Promise<Map<string, { title: string | null; isDone: boolean | null }>> {\n const results = new Map<string, { title: string | null; isDone: boolean | null }>()\n if (!links.length || !tenantId) return results\n\n const idsBySource = new Map<string, Set<string>>()\n for (const link of links) {\n if (!link.todoSource || !link.todoId) continue\n if (!idsBySource.has(link.todoSource)) idsBySource.set(link.todoSource, new Set())\n idsBySource.get(link.todoSource)!.add(link.todoId)\n }\n\n const requestedFields = ['id', ...TITLE_FIELDS, ...IS_DONE_FIELDS]\n const organizationIds = orgId ? [orgId] : undefined\n\n for (const [source, idSet] of idsBySource.entries()) {\n const ids = Array.from(idSet)\n try {\n const result = await queryEngine.query<Record<string, unknown>>(source as EntityId, {\n tenantId,\n organizationIds,\n filters: { id: { $in: ids } },\n fields: requestedFields,\n includeCustomFields: false,\n page: { page: 1, pageSize: Math.max(ids.length, 1) },\n })\n for (const item of result.items ?? []) {\n const raw = item as Record<string, unknown>\n const todoId = typeof raw.id === 'string' ? raw.id : String(raw.id ?? '')\n if (!todoId) continue\n results.set(`${source}:${todoId}`, {\n title: resolveTodoTitle(raw),\n isDone: resolveTodoIsDone(raw),\n })\n }\n } catch {\n // non-critical: todo metadata unavailable, items fall back to null\n }\n }\n\n return results\n}\n\nexport async function GET(request: Request): Promise<Response> {\n const auth = await getAuthFromRequest(request)\n if (!auth?.sub && !auth?.isApiKey) {\n return new Response(JSON.stringify({ error: 'Authentication required' }), {\n status: 401,\n headers: { 'Content-Type': 'application/json' },\n })\n }\n\n const url = new URL(request.url)\n const parsed = querySchema.safeParse({\n page: url.searchParams.get('page') ?? undefined,\n pageSize: url.searchParams.get('pageSize') ?? undefined,\n search: url.searchParams.get('search') ?? undefined,\n all: url.searchParams.get('all') ?? undefined,\n })\n\n if (!parsed.success) {\n return new Response(JSON.stringify({ error: 'Invalid parameters' }), {\n status: 400,\n headers: { 'Content-Type': 'application/json' },\n })\n }\n\n const { page, pageSize, search, all } = parsed.data\n const exportAll = parseBooleanToken(all)\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n\n const where: Record<string, unknown> = {\n tenantId: auth.tenantId,\n }\n if (auth.orgId) {\n where.organizationId = auth.orgId\n }\n\n if (search?.trim()) {\n where.entity = { displayName: { $ilike: `%${search.trim()}%` } }\n }\n\n const [links, total] = await em.findAndCount(\n CustomerTodoLink,\n where,\n {\n populate: ['entity'],\n orderBy: { createdAt: 'desc' },\n ...(exportAll ? {} : {\n offset: (page - 1) * pageSize,\n limit: pageSize,\n }),\n },\n )\n\n await decryptEntitiesWithFallbackScope(links, {\n em,\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n })\n\n const queryEngine = container.resolve('queryEngine') as QueryEngine\n const todoSummaries = await resolveTodoSummaries(queryEngine, links, auth.tenantId, auth.orgId ?? null)\n\n const effectivePage = exportAll ? 1 : page\n const effectivePageSize = exportAll ? total : pageSize\n\n const items = links.map((link) => {\n const summary = todoSummaries.get(`${link.todoSource}:${link.todoId}`) ?? null\n return {\n id: link.id,\n todoId: link.todoId,\n todoSource: link.todoSource,\n todoTitle: summary?.title ?? null,\n todoIsDone: summary?.isDone ?? null,\n todoOrganizationId: link.organizationId,\n organizationId: link.organizationId,\n tenantId: link.tenantId,\n createdAt: link.createdAt.toISOString(),\n customer: {\n id: link.entity.id,\n displayName: link.entity.displayName,\n kind: link.entity.kind,\n },\n }\n })\n\n return new Response(\n JSON.stringify({\n items,\n total,\n page: effectivePage,\n pageSize: effectivePageSize,\n totalPages: exportAll ? 1 : Math.ceil(total / pageSize),\n }),\n { status: 200, headers: { 'Content-Type': 'application/json' } },\n )\n}\n\nconst todoItemSchema = z.object({\n id: z.string(),\n todoId: z.string(),\n todoSource: z.string(),\n todoTitle: z.string().nullable(),\n todoIsDone: z.boolean().nullable(),\n todoOrganizationId: z.string().nullable(),\n organizationId: z.string(),\n tenantId: z.string(),\n createdAt: z.string(),\n customer: z.object({\n id: z.string().nullable(),\n displayName: z.string().nullable(),\n kind: z.string().nullable(),\n }),\n})\n\nexport const openApi: OpenApiRouteDoc = createCustomersCrudOpenApi({\n resourceName: 'CustomerTodo',\n querySchema,\n listResponseSchema: createPagedListResponseSchema(todoItemSchema),\n})\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
5
|
import { useRouter } from "next/navigation";
|
|
6
|
-
import { useQuery } from "@tanstack/react-query";
|
|
6
|
+
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
|
7
7
|
import { DataTable } from "@open-mercato/ui/backend/DataTable";
|
|
8
8
|
import { RowActions } from "@open-mercato/ui/backend/RowActions";
|
|
9
9
|
import { BooleanIcon } from "@open-mercato/ui/backend/ValueIcons";
|
|
@@ -28,17 +28,14 @@ function CustomerTodosTable() {
|
|
|
28
28
|
const [search, setSearch] = React.useState("");
|
|
29
29
|
const [page, setPage] = React.useState(1);
|
|
30
30
|
const [pageSize] = React.useState(50);
|
|
31
|
-
const [filters, setFilters] = React.useState({});
|
|
32
31
|
const params = React.useMemo(() => {
|
|
33
32
|
const usp = new URLSearchParams({
|
|
34
33
|
page: String(page),
|
|
35
34
|
pageSize: String(pageSize)
|
|
36
35
|
});
|
|
37
36
|
if (search.trim().length > 0) usp.set("search", search.trim());
|
|
38
|
-
const doneValue = filters.is_done;
|
|
39
|
-
if (doneValue === "true" || doneValue === "false") usp.set("isDone", doneValue);
|
|
40
37
|
return usp.toString();
|
|
41
|
-
}, [page, pageSize, search
|
|
38
|
+
}, [page, pageSize, search]);
|
|
42
39
|
const columns = React.useMemo(() => [
|
|
43
40
|
{
|
|
44
41
|
accessorKey: "customer.displayName",
|
|
@@ -87,7 +84,8 @@ function CustomerTodosTable() {
|
|
|
87
84
|
void 0,
|
|
88
85
|
{ errorMessage: t("customers.workPlan.customerTodos.table.error.load") }
|
|
89
86
|
);
|
|
90
|
-
}
|
|
87
|
+
},
|
|
88
|
+
placeholderData: keepPreviousData
|
|
91
89
|
});
|
|
92
90
|
const rows = data?.items ?? [];
|
|
93
91
|
const exportConfig = React.useMemo(() => ({
|
|
@@ -114,30 +112,6 @@ function CustomerTodosTable() {
|
|
|
114
112
|
filename: () => "customer_todos_full"
|
|
115
113
|
}
|
|
116
114
|
}), [rows, t, viewExportColumns]);
|
|
117
|
-
const filterDefs = React.useMemo(() => [
|
|
118
|
-
{
|
|
119
|
-
id: "is_done",
|
|
120
|
-
label: t("customers.workPlan.customerTodos.table.filters.done"),
|
|
121
|
-
type: "select",
|
|
122
|
-
options: [
|
|
123
|
-
{ label: t("customers.workPlan.customerTodos.table.filters.doneOption.any"), value: "" },
|
|
124
|
-
{ label: t("customers.workPlan.customerTodos.table.filters.doneOption.open"), value: "false" },
|
|
125
|
-
{ label: t("customers.workPlan.customerTodos.table.filters.doneOption.completed"), value: "true" }
|
|
126
|
-
]
|
|
127
|
-
}
|
|
128
|
-
], [t]);
|
|
129
|
-
const onFiltersApply = React.useCallback((next) => {
|
|
130
|
-
const nextValue = next?.is_done;
|
|
131
|
-
setFilters((prev) => {
|
|
132
|
-
if (prev.is_done === nextValue) return prev;
|
|
133
|
-
return { is_done: nextValue };
|
|
134
|
-
});
|
|
135
|
-
setPage(1);
|
|
136
|
-
}, []);
|
|
137
|
-
const onFiltersClear = React.useCallback(() => {
|
|
138
|
-
setFilters({});
|
|
139
|
-
setPage(1);
|
|
140
|
-
}, []);
|
|
141
115
|
const handleRefresh = React.useCallback(async () => {
|
|
142
116
|
try {
|
|
143
117
|
await refetch();
|
|
@@ -153,66 +127,60 @@ function CustomerTodosTable() {
|
|
|
153
127
|
router.push(href);
|
|
154
128
|
}, [router]);
|
|
155
129
|
const errorMessage = error ? error instanceof Error ? error.message : t("customers.workPlan.customerTodos.table.error.load") : null;
|
|
156
|
-
const
|
|
157
|
-
return /* @__PURE__ */
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
130
|
+
const emptyStateMessage = !isLoading && !errorMessage && rows.length === 0 ? search ? t("customers.workPlan.customerTodos.table.state.noMatches") : t("customers.workPlan.customerTodos.table.state.empty") : void 0;
|
|
131
|
+
return /* @__PURE__ */ jsx(
|
|
132
|
+
DataTable,
|
|
133
|
+
{
|
|
134
|
+
title: t("customers.workPlan.customerTodos.table.title"),
|
|
135
|
+
actions: /* @__PURE__ */ jsx(
|
|
136
|
+
Button,
|
|
137
|
+
{
|
|
138
|
+
variant: "outline",
|
|
139
|
+
onClick: () => {
|
|
140
|
+
void handleRefresh();
|
|
141
|
+
},
|
|
142
|
+
disabled: isFetching,
|
|
143
|
+
children: t("customers.workPlan.customerTodos.table.actions.refresh")
|
|
144
|
+
}
|
|
145
|
+
),
|
|
146
|
+
columns,
|
|
147
|
+
data: rows,
|
|
148
|
+
exporter: exportConfig,
|
|
149
|
+
searchValue: search,
|
|
150
|
+
onSearchChange: (value) => {
|
|
151
|
+
setSearch(value);
|
|
152
|
+
setPage(1);
|
|
153
|
+
},
|
|
154
|
+
perspective: { tableId: "customers.todos.list" },
|
|
155
|
+
rowActions: (row) => {
|
|
156
|
+
const customerLink = buildCustomerHref(row);
|
|
157
|
+
if (!customerLink) return null;
|
|
158
|
+
return /* @__PURE__ */ jsx(
|
|
159
|
+
RowActions,
|
|
164
160
|
{
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
161
|
+
items: [
|
|
162
|
+
{
|
|
163
|
+
id: "open-customer",
|
|
164
|
+
label: t("customers.workPlan.customerTodos.table.actions.openCustomer"),
|
|
165
|
+
href: customerLink
|
|
166
|
+
}
|
|
167
|
+
]
|
|
171
168
|
}
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const customerLink = buildCustomerHref(row);
|
|
188
|
-
if (!customerLink) return null;
|
|
189
|
-
return /* @__PURE__ */ jsx(
|
|
190
|
-
RowActions,
|
|
191
|
-
{
|
|
192
|
-
items: [
|
|
193
|
-
{
|
|
194
|
-
id: "open-customer",
|
|
195
|
-
label: t("customers.workPlan.customerTodos.table.actions.openCustomer"),
|
|
196
|
-
href: customerLink
|
|
197
|
-
}
|
|
198
|
-
]
|
|
199
|
-
}
|
|
200
|
-
);
|
|
201
|
-
},
|
|
202
|
-
onRowClick: handleNavigate,
|
|
203
|
-
pagination: {
|
|
204
|
-
page,
|
|
205
|
-
pageSize,
|
|
206
|
-
total: data?.total ?? 0,
|
|
207
|
-
totalPages: data?.totalPages ?? 0,
|
|
208
|
-
onPageChange: setPage
|
|
209
|
-
},
|
|
210
|
-
isLoading
|
|
211
|
-
}
|
|
212
|
-
),
|
|
213
|
-
errorMessage ? /* @__PURE__ */ jsx("div", { className: "rounded-md border border-destructive/40 bg-destructive/10 px-4 py-2 text-sm text-destructive", children: errorMessage }) : null,
|
|
214
|
-
isEmpty ? /* @__PURE__ */ jsx("div", { className: "py-8 text-sm text-muted-foreground", children: search || filters.is_done ? t("customers.workPlan.customerTodos.table.state.noMatches") : t("customers.workPlan.customerTodos.table.state.empty") }) : null
|
|
215
|
-
] });
|
|
169
|
+
);
|
|
170
|
+
},
|
|
171
|
+
onRowClick: handleNavigate,
|
|
172
|
+
pagination: {
|
|
173
|
+
page,
|
|
174
|
+
pageSize,
|
|
175
|
+
total: data?.total ?? 0,
|
|
176
|
+
totalPages: data?.totalPages ?? 0,
|
|
177
|
+
onPageChange: setPage
|
|
178
|
+
},
|
|
179
|
+
isLoading,
|
|
180
|
+
error: errorMessage,
|
|
181
|
+
emptyState: emptyStateMessage
|
|
182
|
+
}
|
|
183
|
+
);
|
|
216
184
|
}
|
|
217
185
|
var CustomerTodosTable_default = CustomerTodosTable;
|
|
218
186
|
export {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/customers/components/CustomerTodosTable.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { useRouter } from 'next/navigation'\nimport type { ColumnDef } from '@tanstack/react-table'\nimport { useQuery } from '@tanstack/react-query'\nimport { DataTable, type DataTableExportFormat } from '@open-mercato/ui/backend/DataTable'\nimport type { PreparedExport } from '@open-mercato/shared/lib/crud/exporters'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'\nimport { BooleanIcon } from '@open-mercato/ui/backend/ValueIcons'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildCrudExportUrl } from '@open-mercato/ui/backend/utils/crud'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\ntype CustomerTodoItem = {\n id: string\n todoId: string\n todoSource: string\n todoTitle: string | null\n todoIsDone: boolean | null\n todoPriority?: number | null\n todoSeverity?: string | null\n todoDescription?: string | null\n todoDueAt?: string | null\n todoCustomValues?: Record<string, unknown> | null\n todoOrganizationId: string | null\n organizationId: string\n tenantId: string\n createdAt: string\n customer: {\n id: string | null\n displayName: string | null\n kind: string | null\n }\n}\n\ntype CustomerTodosResponse = {\n items: CustomerTodoItem[]\n total: number\n page: number\n pageSize: number\n totalPages: number\n}\n\nconst TASKS_TAB_QUERY = 'tab=tasks'\n\nfunction buildCustomerHref(item: CustomerTodoItem): string | null {\n const customerId = item.customer?.id\n if (!customerId) return null\n const kind = (item.customer?.kind ?? '').toLowerCase()\n const base =\n kind === 'company'\n ? `/backend/customers/companies/${customerId}`\n : `/backend/customers/people/${customerId}`\n return `${base}?${TASKS_TAB_QUERY}`\n}\n\nexport function CustomerTodosTable(): React.JSX.Element {\n const t = useT()\n const router = useRouter()\n const scopeVersion = useOrganizationScopeVersion()\n\n const [search, setSearch] = React.useState('')\n const [page, setPage] = React.useState(1)\n const [pageSize] = React.useState(50)\n const [filters, setFilters] = React.useState<FilterValues>({})\n\n const params = React.useMemo(() => {\n const usp = new URLSearchParams({\n page: String(page),\n pageSize: String(pageSize),\n })\n if (search.trim().length > 0) usp.set('search', search.trim())\n const doneValue = filters.is_done\n if (doneValue === 'true' || doneValue === 'false') usp.set('isDone', doneValue)\n return usp.toString()\n }, [page, pageSize, search, filters])\n\n const columns = React.useMemo<ColumnDef<CustomerTodoItem>[]>(() => [\n {\n accessorKey: 'customer.displayName',\n header: t('customers.workPlan.customerTodos.table.column.customer'),\n cell: ({ row }) => {\n const name = row.original.customer?.displayName\n if (!name) return <span className=\"text-muted-foreground\">\u2014</span>\n const href = buildCustomerHref(row.original)\n if (!href) return name\n return (\n <Link href={href} className=\"underline-offset-2 hover:underline\">\n {name}\n </Link>\n )\n },\n meta: { priority: 1 },\n },\n {\n accessorKey: 'todoTitle',\n header: t('customers.workPlan.customerTodos.table.column.todo'),\n cell: ({ row }) => {\n const title = row.original.todoTitle ?? t('customers.workPlan.customerTodos.table.column.todo.unnamed')\n const todoId = row.original.todoId\n if (!todoId) return <span className=\"text-muted-foreground\">{title}</span>\n return (\n <Link href={`/backend/todos/${todoId}/edit`} className=\"underline-offset-2 hover:underline\">\n {title}\n </Link>\n )\n },\n meta: { priority: 2 },\n },\n {\n accessorKey: 'todoIsDone',\n header: t('customers.workPlan.customerTodos.table.column.done'),\n cell: ({ row }) => <BooleanIcon value={row.original.todoIsDone === true} />,\n meta: { priority: 3 },\n },\n ], [t])\n\n const viewExportColumns = React.useMemo(() => {\n return columns\n .map((col) => {\n const accessorKey = (col as any).accessorKey\n if (!accessorKey || typeof accessorKey !== 'string') return null\n if ((col as any).meta?.hidden) return null\n const header = typeof col.header === 'string'\n ? col.header\n : accessorKey\n return { field: accessorKey, header }\n })\n .filter((col): col is { field: string; header: string } => !!col)\n }, [columns])\n\n const { data, isLoading, error, refetch, isFetching } = useQuery<CustomerTodosResponse>({\n queryKey: ['customers-todos', params, scopeVersion],\n queryFn: async () => {\n return readApiResultOrThrow<CustomerTodosResponse>(\n `/api/customers/todos?${params}`,\n undefined,\n { errorMessage: t('customers.workPlan.customerTodos.table.error.load') },\n )\n },\n })\n\n const rows = data?.items ?? []\n\n const exportConfig = React.useMemo(() => ({\n view: {\n description: t('customers.workPlan.customerTodos.table.export.view'),\n prepare: async (): Promise<{ prepared: PreparedExport; filename: string }> => {\n const rowsForExport = rows.map((row) => {\n const out: Record<string, unknown> = {}\n for (const col of viewExportColumns) {\n out[col.field] = (row as Record<string, unknown>)[col.field]\n }\n return out\n })\n const prepared: PreparedExport = {\n columns: viewExportColumns.map((col) => ({ field: col.field, header: col.header })),\n rows: rowsForExport,\n }\n return { prepared, filename: 'customer_todos_view' }\n },\n },\n full: {\n description: t('customers.workPlan.customerTodos.table.export.full'),\n getUrl: (format: DataTableExportFormat) =>\n buildCrudExportUrl('customers/todos', { exportScope: 'full', all: 'true' }, format),\n filename: () => 'customer_todos_full',\n },\n }), [rows, t, viewExportColumns])\n\n const filterDefs = React.useMemo<FilterDef[]>(() => [\n {\n id: 'is_done',\n label: t('customers.workPlan.customerTodos.table.filters.done'),\n type: 'select',\n options: [\n { label: t('customers.workPlan.customerTodos.table.filters.doneOption.any'), value: '' },\n { label: t('customers.workPlan.customerTodos.table.filters.doneOption.open'), value: 'false' },\n { label: t('customers.workPlan.customerTodos.table.filters.doneOption.completed'), value: 'true' },\n ],\n },\n ], [t])\n\n const onFiltersApply = React.useCallback((next: FilterValues) => {\n const nextValue = next?.is_done\n setFilters((prev) => {\n if (prev.is_done === nextValue) return prev\n return { is_done: nextValue }\n })\n setPage(1)\n }, [])\n\n const onFiltersClear = React.useCallback(() => {\n setFilters({})\n setPage(1)\n }, [])\n\n const handleRefresh = React.useCallback(async () => {\n try {\n await refetch()\n flash(t('customers.workPlan.customerTodos.table.flash.refreshed'), 'success')\n } catch (err) {\n const message = err instanceof Error ? err.message : t('customers.workPlan.customerTodos.table.error.load')\n flash(message, 'error')\n }\n }, [refetch, t])\n\n const handleNavigate = React.useCallback((item: CustomerTodoItem) => {\n const href = buildCustomerHref(item)\n if (!href) return\n router.push(href)\n }, [router])\n\n const errorMessage = error ? (error instanceof Error ? error.message : t('customers.workPlan.customerTodos.table.error.load')) : null\n const isEmpty = !isLoading && !errorMessage && rows.length === 0\n\n return (\n <div className=\"space-y-4\">\n <DataTable\n title={t('customers.workPlan.customerTodos.table.title')}\n actions={(\n <Button\n variant=\"outline\"\n onClick={() => { void handleRefresh() }}\n disabled={isFetching}\n >\n {t('customers.workPlan.customerTodos.table.actions.refresh')}\n </Button>\n )}\n columns={columns}\n data={rows}\n exporter={exportConfig}\n searchValue={search}\n onSearchChange={(value) => {\n setSearch(value)\n setPage(1)\n }}\n perspective={{ tableId: 'customers.todos.list' }}\n filters={filterDefs}\n filterValues={filters}\n onFiltersApply={onFiltersApply}\n onFiltersClear={onFiltersClear}\n rowActions={(row) => {\n const customerLink = buildCustomerHref(row)\n if (!customerLink) return null\n return (\n <RowActions\n items={[\n {\n id: 'open-customer',\n label: t('customers.workPlan.customerTodos.table.actions.openCustomer'),\n href: customerLink,\n },\n ]}\n />\n )\n }}\n onRowClick={handleNavigate}\n pagination={{\n page,\n pageSize,\n total: data?.total ?? 0,\n totalPages: data?.totalPages ?? 0,\n onPageChange: setPage,\n }}\n isLoading={isLoading}\n />\n {errorMessage ? (\n <div className=\"rounded-md border border-destructive/40 bg-destructive/10 px-4 py-2 text-sm text-destructive\">\n {errorMessage}\n </div>\n ) : null}\n {isEmpty ? (\n <div className=\"py-8 text-sm text-muted-foreground\">\n {search || filters.is_done\n ? t('customers.workPlan.customerTodos.table.state.noMatches')\n : t('customers.workPlan.customerTodos.table.state.empty')}\n </div>\n ) : null}\n </div>\n )\n}\n\nexport default CustomerTodosTable\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { useRouter } from 'next/navigation'\nimport type { ColumnDef } from '@tanstack/react-table'\nimport { useQuery, keepPreviousData } from '@tanstack/react-query'\nimport { DataTable, type DataTableExportFormat } from '@open-mercato/ui/backend/DataTable'\nimport type { PreparedExport } from '@open-mercato/shared/lib/crud/exporters'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport { BooleanIcon } from '@open-mercato/ui/backend/ValueIcons'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildCrudExportUrl } from '@open-mercato/ui/backend/utils/crud'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\ntype CustomerTodoItem = {\n id: string\n todoId: string\n todoSource: string\n todoTitle: string | null\n todoIsDone: boolean | null\n todoPriority?: number | null\n todoSeverity?: string | null\n todoDescription?: string | null\n todoDueAt?: string | null\n todoCustomValues?: Record<string, unknown> | null\n todoOrganizationId: string | null\n organizationId: string\n tenantId: string\n createdAt: string\n customer: {\n id: string | null\n displayName: string | null\n kind: string | null\n }\n}\n\ntype CustomerTodosResponse = {\n items: CustomerTodoItem[]\n total: number\n page: number\n pageSize: number\n totalPages: number\n}\n\nconst TASKS_TAB_QUERY = 'tab=tasks'\n\nfunction buildCustomerHref(item: CustomerTodoItem): string | null {\n const customerId = item.customer?.id\n if (!customerId) return null\n const kind = (item.customer?.kind ?? '').toLowerCase()\n const base =\n kind === 'company'\n ? `/backend/customers/companies/${customerId}`\n : `/backend/customers/people/${customerId}`\n return `${base}?${TASKS_TAB_QUERY}`\n}\n\nexport function CustomerTodosTable(): React.JSX.Element {\n const t = useT()\n const router = useRouter()\n const scopeVersion = useOrganizationScopeVersion()\n\n const [search, setSearch] = React.useState('')\n const [page, setPage] = React.useState(1)\n const [pageSize] = React.useState(50)\n\n const params = React.useMemo(() => {\n const usp = new URLSearchParams({\n page: String(page),\n pageSize: String(pageSize),\n })\n if (search.trim().length > 0) usp.set('search', search.trim())\n return usp.toString()\n }, [page, pageSize, search])\n\n const columns = React.useMemo<ColumnDef<CustomerTodoItem>[]>(() => [\n {\n accessorKey: 'customer.displayName',\n header: t('customers.workPlan.customerTodos.table.column.customer'),\n cell: ({ row }) => {\n const name = row.original.customer?.displayName\n if (!name) return <span className=\"text-muted-foreground\">\u2014</span>\n const href = buildCustomerHref(row.original)\n if (!href) return name\n return (\n <Link href={href} className=\"underline-offset-2 hover:underline\">\n {name}\n </Link>\n )\n },\n meta: { priority: 1 },\n },\n {\n accessorKey: 'todoTitle',\n header: t('customers.workPlan.customerTodos.table.column.todo'),\n cell: ({ row }) => {\n const title = row.original.todoTitle ?? t('customers.workPlan.customerTodos.table.column.todo.unnamed')\n const todoId = row.original.todoId\n if (!todoId) return <span className=\"text-muted-foreground\">{title}</span>\n return (\n <Link href={`/backend/todos/${todoId}/edit`} className=\"underline-offset-2 hover:underline\">\n {title}\n </Link>\n )\n },\n meta: { priority: 2 },\n },\n {\n accessorKey: 'todoIsDone',\n header: t('customers.workPlan.customerTodos.table.column.done'),\n cell: ({ row }) => <BooleanIcon value={row.original.todoIsDone === true} />,\n meta: { priority: 3 },\n },\n ], [t])\n\n const viewExportColumns = React.useMemo(() => {\n return columns\n .map((col) => {\n const accessorKey = (col as any).accessorKey\n if (!accessorKey || typeof accessorKey !== 'string') return null\n if ((col as any).meta?.hidden) return null\n const header = typeof col.header === 'string'\n ? col.header\n : accessorKey\n return { field: accessorKey, header }\n })\n .filter((col): col is { field: string; header: string } => !!col)\n }, [columns])\n\n const { data, isLoading, error, refetch, isFetching } = useQuery<CustomerTodosResponse>({\n queryKey: ['customers-todos', params, scopeVersion],\n queryFn: async () => {\n return readApiResultOrThrow<CustomerTodosResponse>(\n `/api/customers/todos?${params}`,\n undefined,\n { errorMessage: t('customers.workPlan.customerTodos.table.error.load') },\n )\n },\n placeholderData: keepPreviousData,\n })\n\n const rows = data?.items ?? []\n\n const exportConfig = React.useMemo(() => ({\n view: {\n description: t('customers.workPlan.customerTodos.table.export.view'),\n prepare: async (): Promise<{ prepared: PreparedExport; filename: string }> => {\n const rowsForExport = rows.map((row) => {\n const out: Record<string, unknown> = {}\n for (const col of viewExportColumns) {\n out[col.field] = (row as Record<string, unknown>)[col.field]\n }\n return out\n })\n const prepared: PreparedExport = {\n columns: viewExportColumns.map((col) => ({ field: col.field, header: col.header })),\n rows: rowsForExport,\n }\n return { prepared, filename: 'customer_todos_view' }\n },\n },\n full: {\n description: t('customers.workPlan.customerTodos.table.export.full'),\n getUrl: (format: DataTableExportFormat) =>\n buildCrudExportUrl('customers/todos', { exportScope: 'full', all: 'true' }, format),\n filename: () => 'customer_todos_full',\n },\n }), [rows, t, viewExportColumns])\n\n const handleRefresh = React.useCallback(async () => {\n try {\n await refetch()\n flash(t('customers.workPlan.customerTodos.table.flash.refreshed'), 'success')\n } catch (err) {\n const message = err instanceof Error ? err.message : t('customers.workPlan.customerTodos.table.error.load')\n flash(message, 'error')\n }\n }, [refetch, t])\n\n const handleNavigate = React.useCallback((item: CustomerTodoItem) => {\n const href = buildCustomerHref(item)\n if (!href) return\n router.push(href)\n }, [router])\n\n const errorMessage = error ? (error instanceof Error ? error.message : t('customers.workPlan.customerTodos.table.error.load')) : null\n const emptyStateMessage = !isLoading && !errorMessage && rows.length === 0\n ? (search ? t('customers.workPlan.customerTodos.table.state.noMatches') : t('customers.workPlan.customerTodos.table.state.empty'))\n : undefined\n\n return (\n <DataTable\n title={t('customers.workPlan.customerTodos.table.title')}\n actions={(\n <Button\n variant=\"outline\"\n onClick={() => { void handleRefresh() }}\n disabled={isFetching}\n >\n {t('customers.workPlan.customerTodos.table.actions.refresh')}\n </Button>\n )}\n columns={columns}\n data={rows}\n exporter={exportConfig}\n searchValue={search}\n onSearchChange={(value) => {\n setSearch(value)\n setPage(1)\n }}\n perspective={{ tableId: 'customers.todos.list' }}\n rowActions={(row) => {\n const customerLink = buildCustomerHref(row)\n if (!customerLink) return null\n return (\n <RowActions\n items={[\n {\n id: 'open-customer',\n label: t('customers.workPlan.customerTodos.table.actions.openCustomer'),\n href: customerLink,\n },\n ]}\n />\n )\n }}\n onRowClick={handleNavigate}\n pagination={{\n page,\n pageSize,\n total: data?.total ?? 0,\n totalPages: data?.totalPages ?? 0,\n onPageChange: setPage,\n }}\n isLoading={isLoading}\n error={errorMessage}\n emptyState={emptyStateMessage}\n />\n )\n}\n\nexport default CustomerTodosTable\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.4.6-develop-
|
|
3
|
+
"version": "0.4.6-develop-a88276bc52",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -207,7 +207,7 @@
|
|
|
207
207
|
}
|
|
208
208
|
},
|
|
209
209
|
"dependencies": {
|
|
210
|
-
"@open-mercato/shared": "0.4.6-develop-
|
|
210
|
+
"@open-mercato/shared": "0.4.6-develop-a88276bc52",
|
|
211
211
|
"@types/html-to-text": "^9.0.4",
|
|
212
212
|
"@types/semver": "^7.5.8",
|
|
213
213
|
"@xyflow/react": "^12.6.0",
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
3
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
5
|
+
import { CustomerTodoLink } from '../../data/entities'
|
|
6
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
7
|
+
import { createCustomersCrudOpenApi, createPagedListResponseSchema } from '../openapi'
|
|
8
|
+
import { decryptEntitiesWithFallbackScope } from '@open-mercato/shared/lib/encryption/subscriber'
|
|
9
|
+
import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
|
|
10
|
+
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
11
|
+
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
12
|
+
|
|
13
|
+
const querySchema = z.object({
|
|
14
|
+
page: z.coerce.number().min(1).default(1),
|
|
15
|
+
pageSize: z.coerce.number().min(1).max(100).default(50),
|
|
16
|
+
search: z.string().optional(),
|
|
17
|
+
all: z.string().optional(),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export const metadata = {
|
|
21
|
+
GET: { requireAuth: true, requireFeatures: ['customers.view'] },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const TITLE_FIELDS = ['title', 'subject', 'name', 'summary', 'text'] as const
|
|
25
|
+
const IS_DONE_FIELDS = ['is_done', 'isDone', 'done', 'completed'] as const
|
|
26
|
+
|
|
27
|
+
function resolveTodoTitle(raw: Record<string, unknown>): string | null {
|
|
28
|
+
for (const key of TITLE_FIELDS) {
|
|
29
|
+
const value = raw[key]
|
|
30
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
31
|
+
}
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveTodoIsDone(raw: Record<string, unknown>): boolean | null {
|
|
36
|
+
for (const key of IS_DONE_FIELDS) {
|
|
37
|
+
const value = raw[key]
|
|
38
|
+
if (typeof value === 'boolean') return value
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function resolveTodoSummaries(
|
|
44
|
+
queryEngine: QueryEngine,
|
|
45
|
+
links: CustomerTodoLink[],
|
|
46
|
+
tenantId: string | null,
|
|
47
|
+
orgId: string | null,
|
|
48
|
+
): Promise<Map<string, { title: string | null; isDone: boolean | null }>> {
|
|
49
|
+
const results = new Map<string, { title: string | null; isDone: boolean | null }>()
|
|
50
|
+
if (!links.length || !tenantId) return results
|
|
51
|
+
|
|
52
|
+
const idsBySource = new Map<string, Set<string>>()
|
|
53
|
+
for (const link of links) {
|
|
54
|
+
if (!link.todoSource || !link.todoId) continue
|
|
55
|
+
if (!idsBySource.has(link.todoSource)) idsBySource.set(link.todoSource, new Set())
|
|
56
|
+
idsBySource.get(link.todoSource)!.add(link.todoId)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const requestedFields = ['id', ...TITLE_FIELDS, ...IS_DONE_FIELDS]
|
|
60
|
+
const organizationIds = orgId ? [orgId] : undefined
|
|
61
|
+
|
|
62
|
+
for (const [source, idSet] of idsBySource.entries()) {
|
|
63
|
+
const ids = Array.from(idSet)
|
|
64
|
+
try {
|
|
65
|
+
const result = await queryEngine.query<Record<string, unknown>>(source as EntityId, {
|
|
66
|
+
tenantId,
|
|
67
|
+
organizationIds,
|
|
68
|
+
filters: { id: { $in: ids } },
|
|
69
|
+
fields: requestedFields,
|
|
70
|
+
includeCustomFields: false,
|
|
71
|
+
page: { page: 1, pageSize: Math.max(ids.length, 1) },
|
|
72
|
+
})
|
|
73
|
+
for (const item of result.items ?? []) {
|
|
74
|
+
const raw = item as Record<string, unknown>
|
|
75
|
+
const todoId = typeof raw.id === 'string' ? raw.id : String(raw.id ?? '')
|
|
76
|
+
if (!todoId) continue
|
|
77
|
+
results.set(`${source}:${todoId}`, {
|
|
78
|
+
title: resolveTodoTitle(raw),
|
|
79
|
+
isDone: resolveTodoIsDone(raw),
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// non-critical: todo metadata unavailable, items fall back to null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return results
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function GET(request: Request): Promise<Response> {
|
|
91
|
+
const auth = await getAuthFromRequest(request)
|
|
92
|
+
if (!auth?.sub && !auth?.isApiKey) {
|
|
93
|
+
return new Response(JSON.stringify({ error: 'Authentication required' }), {
|
|
94
|
+
status: 401,
|
|
95
|
+
headers: { 'Content-Type': 'application/json' },
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const url = new URL(request.url)
|
|
100
|
+
const parsed = querySchema.safeParse({
|
|
101
|
+
page: url.searchParams.get('page') ?? undefined,
|
|
102
|
+
pageSize: url.searchParams.get('pageSize') ?? undefined,
|
|
103
|
+
search: url.searchParams.get('search') ?? undefined,
|
|
104
|
+
all: url.searchParams.get('all') ?? undefined,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
if (!parsed.success) {
|
|
108
|
+
return new Response(JSON.stringify({ error: 'Invalid parameters' }), {
|
|
109
|
+
status: 400,
|
|
110
|
+
headers: { 'Content-Type': 'application/json' },
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { page, pageSize, search, all } = parsed.data
|
|
115
|
+
const exportAll = parseBooleanToken(all)
|
|
116
|
+
|
|
117
|
+
const container = await createRequestContainer()
|
|
118
|
+
const em = container.resolve('em') as EntityManager
|
|
119
|
+
|
|
120
|
+
const where: Record<string, unknown> = {
|
|
121
|
+
tenantId: auth.tenantId,
|
|
122
|
+
}
|
|
123
|
+
if (auth.orgId) {
|
|
124
|
+
where.organizationId = auth.orgId
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (search?.trim()) {
|
|
128
|
+
where.entity = { displayName: { $ilike: `%${search.trim()}%` } }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const [links, total] = await em.findAndCount(
|
|
132
|
+
CustomerTodoLink,
|
|
133
|
+
where,
|
|
134
|
+
{
|
|
135
|
+
populate: ['entity'],
|
|
136
|
+
orderBy: { createdAt: 'desc' },
|
|
137
|
+
...(exportAll ? {} : {
|
|
138
|
+
offset: (page - 1) * pageSize,
|
|
139
|
+
limit: pageSize,
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
await decryptEntitiesWithFallbackScope(links, {
|
|
145
|
+
em,
|
|
146
|
+
tenantId: auth.tenantId,
|
|
147
|
+
organizationId: auth.orgId ?? null,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const queryEngine = container.resolve('queryEngine') as QueryEngine
|
|
151
|
+
const todoSummaries = await resolveTodoSummaries(queryEngine, links, auth.tenantId, auth.orgId ?? null)
|
|
152
|
+
|
|
153
|
+
const effectivePage = exportAll ? 1 : page
|
|
154
|
+
const effectivePageSize = exportAll ? total : pageSize
|
|
155
|
+
|
|
156
|
+
const items = links.map((link) => {
|
|
157
|
+
const summary = todoSummaries.get(`${link.todoSource}:${link.todoId}`) ?? null
|
|
158
|
+
return {
|
|
159
|
+
id: link.id,
|
|
160
|
+
todoId: link.todoId,
|
|
161
|
+
todoSource: link.todoSource,
|
|
162
|
+
todoTitle: summary?.title ?? null,
|
|
163
|
+
todoIsDone: summary?.isDone ?? null,
|
|
164
|
+
todoOrganizationId: link.organizationId,
|
|
165
|
+
organizationId: link.organizationId,
|
|
166
|
+
tenantId: link.tenantId,
|
|
167
|
+
createdAt: link.createdAt.toISOString(),
|
|
168
|
+
customer: {
|
|
169
|
+
id: link.entity.id,
|
|
170
|
+
displayName: link.entity.displayName,
|
|
171
|
+
kind: link.entity.kind,
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
return new Response(
|
|
177
|
+
JSON.stringify({
|
|
178
|
+
items,
|
|
179
|
+
total,
|
|
180
|
+
page: effectivePage,
|
|
181
|
+
pageSize: effectivePageSize,
|
|
182
|
+
totalPages: exportAll ? 1 : Math.ceil(total / pageSize),
|
|
183
|
+
}),
|
|
184
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const todoItemSchema = z.object({
|
|
189
|
+
id: z.string(),
|
|
190
|
+
todoId: z.string(),
|
|
191
|
+
todoSource: z.string(),
|
|
192
|
+
todoTitle: z.string().nullable(),
|
|
193
|
+
todoIsDone: z.boolean().nullable(),
|
|
194
|
+
todoOrganizationId: z.string().nullable(),
|
|
195
|
+
organizationId: z.string(),
|
|
196
|
+
tenantId: z.string(),
|
|
197
|
+
createdAt: z.string(),
|
|
198
|
+
customer: z.object({
|
|
199
|
+
id: z.string().nullable(),
|
|
200
|
+
displayName: z.string().nullable(),
|
|
201
|
+
kind: z.string().nullable(),
|
|
202
|
+
}),
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
export const openApi: OpenApiRouteDoc = createCustomersCrudOpenApi({
|
|
206
|
+
resourceName: 'CustomerTodo',
|
|
207
|
+
querySchema,
|
|
208
|
+
listResponseSchema: createPagedListResponseSchema(todoItemSchema),
|
|
209
|
+
})
|
|
@@ -4,11 +4,10 @@ import * as React from 'react'
|
|
|
4
4
|
import Link from 'next/link'
|
|
5
5
|
import { useRouter } from 'next/navigation'
|
|
6
6
|
import type { ColumnDef } from '@tanstack/react-table'
|
|
7
|
-
import { useQuery } from '@tanstack/react-query'
|
|
7
|
+
import { useQuery, keepPreviousData } from '@tanstack/react-query'
|
|
8
8
|
import { DataTable, type DataTableExportFormat } from '@open-mercato/ui/backend/DataTable'
|
|
9
9
|
import type { PreparedExport } from '@open-mercato/shared/lib/crud/exporters'
|
|
10
10
|
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
11
|
-
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
12
11
|
import { BooleanIcon } from '@open-mercato/ui/backend/ValueIcons'
|
|
13
12
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
14
13
|
import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
@@ -68,7 +67,6 @@ export function CustomerTodosTable(): React.JSX.Element {
|
|
|
68
67
|
const [search, setSearch] = React.useState('')
|
|
69
68
|
const [page, setPage] = React.useState(1)
|
|
70
69
|
const [pageSize] = React.useState(50)
|
|
71
|
-
const [filters, setFilters] = React.useState<FilterValues>({})
|
|
72
70
|
|
|
73
71
|
const params = React.useMemo(() => {
|
|
74
72
|
const usp = new URLSearchParams({
|
|
@@ -76,10 +74,8 @@ export function CustomerTodosTable(): React.JSX.Element {
|
|
|
76
74
|
pageSize: String(pageSize),
|
|
77
75
|
})
|
|
78
76
|
if (search.trim().length > 0) usp.set('search', search.trim())
|
|
79
|
-
const doneValue = filters.is_done
|
|
80
|
-
if (doneValue === 'true' || doneValue === 'false') usp.set('isDone', doneValue)
|
|
81
77
|
return usp.toString()
|
|
82
|
-
}, [page, pageSize, search
|
|
78
|
+
}, [page, pageSize, search])
|
|
83
79
|
|
|
84
80
|
const columns = React.useMemo<ColumnDef<CustomerTodoItem>[]>(() => [
|
|
85
81
|
{
|
|
@@ -144,6 +140,7 @@ export function CustomerTodosTable(): React.JSX.Element {
|
|
|
144
140
|
{ errorMessage: t('customers.workPlan.customerTodos.table.error.load') },
|
|
145
141
|
)
|
|
146
142
|
},
|
|
143
|
+
placeholderData: keepPreviousData,
|
|
147
144
|
})
|
|
148
145
|
|
|
149
146
|
const rows = data?.items ?? []
|
|
@@ -174,33 +171,6 @@ export function CustomerTodosTable(): React.JSX.Element {
|
|
|
174
171
|
},
|
|
175
172
|
}), [rows, t, viewExportColumns])
|
|
176
173
|
|
|
177
|
-
const filterDefs = React.useMemo<FilterDef[]>(() => [
|
|
178
|
-
{
|
|
179
|
-
id: 'is_done',
|
|
180
|
-
label: t('customers.workPlan.customerTodos.table.filters.done'),
|
|
181
|
-
type: 'select',
|
|
182
|
-
options: [
|
|
183
|
-
{ label: t('customers.workPlan.customerTodos.table.filters.doneOption.any'), value: '' },
|
|
184
|
-
{ label: t('customers.workPlan.customerTodos.table.filters.doneOption.open'), value: 'false' },
|
|
185
|
-
{ label: t('customers.workPlan.customerTodos.table.filters.doneOption.completed'), value: 'true' },
|
|
186
|
-
],
|
|
187
|
-
},
|
|
188
|
-
], [t])
|
|
189
|
-
|
|
190
|
-
const onFiltersApply = React.useCallback((next: FilterValues) => {
|
|
191
|
-
const nextValue = next?.is_done
|
|
192
|
-
setFilters((prev) => {
|
|
193
|
-
if (prev.is_done === nextValue) return prev
|
|
194
|
-
return { is_done: nextValue }
|
|
195
|
-
})
|
|
196
|
-
setPage(1)
|
|
197
|
-
}, [])
|
|
198
|
-
|
|
199
|
-
const onFiltersClear = React.useCallback(() => {
|
|
200
|
-
setFilters({})
|
|
201
|
-
setPage(1)
|
|
202
|
-
}, [])
|
|
203
|
-
|
|
204
174
|
const handleRefresh = React.useCallback(async () => {
|
|
205
175
|
try {
|
|
206
176
|
await refetch()
|
|
@@ -218,16 +188,17 @@ export function CustomerTodosTable(): React.JSX.Element {
|
|
|
218
188
|
}, [router])
|
|
219
189
|
|
|
220
190
|
const errorMessage = error ? (error instanceof Error ? error.message : t('customers.workPlan.customerTodos.table.error.load')) : null
|
|
221
|
-
const
|
|
191
|
+
const emptyStateMessage = !isLoading && !errorMessage && rows.length === 0
|
|
192
|
+
? (search ? t('customers.workPlan.customerTodos.table.state.noMatches') : t('customers.workPlan.customerTodos.table.state.empty'))
|
|
193
|
+
: undefined
|
|
222
194
|
|
|
223
195
|
return (
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
onClick={() => { void handleRefresh() }}
|
|
196
|
+
<DataTable
|
|
197
|
+
title={t('customers.workPlan.customerTodos.table.title')}
|
|
198
|
+
actions={(
|
|
199
|
+
<Button
|
|
200
|
+
variant="outline"
|
|
201
|
+
onClick={() => { void handleRefresh() }}
|
|
231
202
|
disabled={isFetching}
|
|
232
203
|
>
|
|
233
204
|
{t('customers.workPlan.customerTodos.table.actions.refresh')}
|
|
@@ -242,10 +213,6 @@ export function CustomerTodosTable(): React.JSX.Element {
|
|
|
242
213
|
setPage(1)
|
|
243
214
|
}}
|
|
244
215
|
perspective={{ tableId: 'customers.todos.list' }}
|
|
245
|
-
filters={filterDefs}
|
|
246
|
-
filterValues={filters}
|
|
247
|
-
onFiltersApply={onFiltersApply}
|
|
248
|
-
onFiltersClear={onFiltersClear}
|
|
249
216
|
rowActions={(row) => {
|
|
250
217
|
const customerLink = buildCustomerHref(row)
|
|
251
218
|
if (!customerLink) return null
|
|
@@ -270,20 +237,9 @@ export function CustomerTodosTable(): React.JSX.Element {
|
|
|
270
237
|
onPageChange: setPage,
|
|
271
238
|
}}
|
|
272
239
|
isLoading={isLoading}
|
|
273
|
-
|
|
274
|
-
{
|
|
275
|
-
|
|
276
|
-
{errorMessage}
|
|
277
|
-
</div>
|
|
278
|
-
) : null}
|
|
279
|
-
{isEmpty ? (
|
|
280
|
-
<div className="py-8 text-sm text-muted-foreground">
|
|
281
|
-
{search || filters.is_done
|
|
282
|
-
? t('customers.workPlan.customerTodos.table.state.noMatches')
|
|
283
|
-
: t('customers.workPlan.customerTodos.table.state.empty')}
|
|
284
|
-
</div>
|
|
285
|
-
) : null}
|
|
286
|
-
</div>
|
|
240
|
+
error={errorMessage}
|
|
241
|
+
emptyState={emptyStateMessage}
|
|
242
|
+
/>
|
|
287
243
|
)
|
|
288
244
|
}
|
|
289
245
|
|