@goplusvn/core 0.1.60 → 0.1.62
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/package.json +1 -1
- package/src/crud/lib/mutation-builder.ts +105 -0
- package/src/crud/lib/query-builder.ts +119 -0
- package/src/crud/server-service.ts +35 -163
- package/src/user/components/users-card-view.tsx +173 -179
- package/src/user/pages/users-client-page.tsx +188 -123
package/package.json
CHANGED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { EntityConfig } from "../../types";
|
|
2
|
+
import type { ServerCrudLogger } from "../server-service";
|
|
3
|
+
|
|
4
|
+
export interface PrepareMutationDataOptions {
|
|
5
|
+
data: any;
|
|
6
|
+
config?: EntityConfig;
|
|
7
|
+
mode: "create" | "update";
|
|
8
|
+
systemFields: string[];
|
|
9
|
+
relationFieldSkip?: string[];
|
|
10
|
+
logger: ServerCrudLogger;
|
|
11
|
+
touchUpdatedAtOnCreate?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function prepareMutationData(options: PrepareMutationDataOptions) {
|
|
15
|
+
const { data, config, mode, systemFields, relationFieldSkip, logger, touchUpdatedAtOnCreate } = options;
|
|
16
|
+
|
|
17
|
+
let filtered = data;
|
|
18
|
+
|
|
19
|
+
if (config) {
|
|
20
|
+
// 1. Filter valid fields
|
|
21
|
+
const valid = new Set(config.fields.filter((f) => !(f as any).isDisplayOnly).map((f) => f.name));
|
|
22
|
+
for (const f of systemFields) valid.add(f);
|
|
23
|
+
filtered = {};
|
|
24
|
+
for (const [k, v] of Object.entries(data)) {
|
|
25
|
+
if (valid.has(k)) {
|
|
26
|
+
filtered[k] = v;
|
|
27
|
+
} else {
|
|
28
|
+
logger.warn(`Filtering out invalid field "${k}" for entity "${config.name}"`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 2. Cast field values (boolean, number)
|
|
33
|
+
for (const field of config.fields) {
|
|
34
|
+
const value = filtered[field.name];
|
|
35
|
+
if (value === undefined || value === null) continue;
|
|
36
|
+
|
|
37
|
+
if (field.type === "boolean" || field.type === "switch") {
|
|
38
|
+
let isTrue: boolean;
|
|
39
|
+
if (typeof value === "string") {
|
|
40
|
+
const lv = value.toLowerCase();
|
|
41
|
+
isTrue = lv === "true" || lv === "active" || value === "1" || value === "on";
|
|
42
|
+
} else {
|
|
43
|
+
isTrue = Boolean(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (field.type === "switch" && field.options && field.options.length >= 2) {
|
|
47
|
+
filtered[field.name] = isTrue ? (field.options[0] as any).value : (field.options[1] as any).value;
|
|
48
|
+
} else {
|
|
49
|
+
filtered[field.name] = isTrue;
|
|
50
|
+
}
|
|
51
|
+
} else if (field.type === "number" || (field.type as string) === "integer") {
|
|
52
|
+
if (typeof value === "string") {
|
|
53
|
+
if (value.trim() === "") {
|
|
54
|
+
filtered[field.name] = null;
|
|
55
|
+
} else {
|
|
56
|
+
const num = Number(value);
|
|
57
|
+
if (!isNaN(num)) filtered[field.name] = num;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 3. System defaults for create
|
|
65
|
+
if (mode === "create") {
|
|
66
|
+
if (!filtered.id) {
|
|
67
|
+
filtered.id = crypto.randomUUID();
|
|
68
|
+
}
|
|
69
|
+
if (touchUpdatedAtOnCreate && !filtered.updatedAt) {
|
|
70
|
+
filtered.updatedAt = new Date();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 4. Transform relation fields (*Id -> connect/disconnect)
|
|
75
|
+
const skip = new Set([
|
|
76
|
+
"id",
|
|
77
|
+
"createdBy",
|
|
78
|
+
"updatedBy",
|
|
79
|
+
"citizenId",
|
|
80
|
+
"targetId",
|
|
81
|
+
...(relationFieldSkip ?? []),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const prismaData = { ...filtered };
|
|
85
|
+
for (const key of Object.keys(prismaData)) {
|
|
86
|
+
if (skip.has(key)) continue;
|
|
87
|
+
|
|
88
|
+
if (key.endsWith("Id") && key.length > 2) {
|
|
89
|
+
const rel = key.slice(0, -2);
|
|
90
|
+
const value = prismaData[key];
|
|
91
|
+
|
|
92
|
+
if (value && typeof value === "string" && value.trim() !== "") {
|
|
93
|
+
prismaData[rel] = { connect: { id: value } };
|
|
94
|
+
delete prismaData[key];
|
|
95
|
+
} else if (value === null || value === undefined || value === "") {
|
|
96
|
+
if (mode === "update") {
|
|
97
|
+
prismaData[rel] = { disconnect: true };
|
|
98
|
+
}
|
|
99
|
+
delete prismaData[key];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return prismaData;
|
|
105
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { compileFilterTree } from "./filter-tree";
|
|
2
|
+
import type { CrudQueryParams, EntityConfig } from "../../types";
|
|
3
|
+
|
|
4
|
+
export interface BuildListQueryOptions {
|
|
5
|
+
entity: string;
|
|
6
|
+
config: EntityConfig;
|
|
7
|
+
params: CrudQueryParams;
|
|
8
|
+
scopeWhere?: Record<string, unknown>;
|
|
9
|
+
onDisallowedFilter?: (field: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function buildListQuery({
|
|
13
|
+
entity,
|
|
14
|
+
config,
|
|
15
|
+
params,
|
|
16
|
+
scopeWhere,
|
|
17
|
+
onDisallowedFilter,
|
|
18
|
+
}: BuildListQueryOptions) {
|
|
19
|
+
const { page = 1, pageSize = 10, search, sort, filters, filterTree } = params;
|
|
20
|
+
const MAX_PAGE_SIZE = 200;
|
|
21
|
+
|
|
22
|
+
const safePage = Math.max(1, Number(page) || 1);
|
|
23
|
+
const safePageSize = Math.min(Math.max(1, Number(pageSize) || 10), MAX_PAGE_SIZE);
|
|
24
|
+
const skip = (safePage - 1) * safePageSize;
|
|
25
|
+
const take = safePageSize;
|
|
26
|
+
|
|
27
|
+
const allowedFields = new Set<string>([
|
|
28
|
+
...config.fields.map((f) => f.name),
|
|
29
|
+
"id", "createdAt", "updatedAt", "createdBy", "updatedBy", config.idField || "id",
|
|
30
|
+
]);
|
|
31
|
+
const allowedRelations = new Set<string>(config.include || []);
|
|
32
|
+
const isAllowed = (name: string) => {
|
|
33
|
+
if (!name) return false;
|
|
34
|
+
if (name.includes(".")) return allowedRelations.has(name.split(".")[0]);
|
|
35
|
+
return allowedFields.has(name);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const where: any = { ...(scopeWhere ?? {}) };
|
|
39
|
+
if (search && search.trim()) {
|
|
40
|
+
const term = search.trim();
|
|
41
|
+
const searchFields = config.fields.filter((f) => f.filterable && f.type === "text").map((f) => f.name);
|
|
42
|
+
if (searchFields.length) where.OR = searchFields.map((f) => ({ [f]: { contains: term, mode: "insensitive" } }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (filters && filters.length) {
|
|
46
|
+
for (const filter of filters) {
|
|
47
|
+
const { name, value, operator } = filter as any;
|
|
48
|
+
if (value === undefined || value === null || (Array.isArray(value) && value.length === 0)) continue;
|
|
49
|
+
if (!isAllowed(name)) {
|
|
50
|
+
if (onDisallowedFilter) onDisallowedFilter(name);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let target = where;
|
|
54
|
+
let key = name;
|
|
55
|
+
if (name.includes(".")) {
|
|
56
|
+
const parts = name.split(".");
|
|
57
|
+
key = parts.pop()!;
|
|
58
|
+
for (const p of parts) { if (!target[p]) target[p] = {}; target = target[p]; }
|
|
59
|
+
}
|
|
60
|
+
const op = operator as string;
|
|
61
|
+
if (op === "contains") target[key] = { contains: value, mode: "insensitive" };
|
|
62
|
+
else if (op === "in") target[key] = { in: value };
|
|
63
|
+
else if (op === "notIn") target[key] = { notIn: value };
|
|
64
|
+
else if (op === "eq") target[key] = value;
|
|
65
|
+
else if (op === "ne") target[key] = { not: value };
|
|
66
|
+
else if (op === "gt") target[key] = { gt: value };
|
|
67
|
+
else if (op === "gte") target[key] = { gte: value };
|
|
68
|
+
else if (op === "lt") target[key] = { lt: value };
|
|
69
|
+
else if (op === "lte") target[key] = { lte: value };
|
|
70
|
+
else if (op === "startsWith") target[key] = { startsWith: value, mode: "insensitive" };
|
|
71
|
+
else if (op === "endsWith") target[key] = { endsWith: value, mode: "insensitive" };
|
|
72
|
+
else if (op === "isNull") target[key] = null;
|
|
73
|
+
else if (op === "isNotNull") target[key] = { not: null };
|
|
74
|
+
else target[key] = value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (filterTree) {
|
|
79
|
+
const compiled = compileFilterTree(filterTree, {
|
|
80
|
+
isAllowed,
|
|
81
|
+
onDisallowed: (field) => {
|
|
82
|
+
if (onDisallowedFilter) onDisallowedFilter(field);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
if (compiled) {
|
|
86
|
+
where.AND = [...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []), compiled];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const orderBy: any = {};
|
|
91
|
+
const applySort = (field: string, direction: any) => {
|
|
92
|
+
if (field.includes(".")) {
|
|
93
|
+
const parts = field.split(".");
|
|
94
|
+
const leaf = parts.pop()!;
|
|
95
|
+
let t = orderBy;
|
|
96
|
+
for (const p of parts) { t[p] = t[p] || {}; t = t[p]; }
|
|
97
|
+
t[leaf] = direction;
|
|
98
|
+
} else orderBy[field] = direction;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (sort && isAllowed(sort.field)) applySort(sort.field, sort.direction);
|
|
102
|
+
else if (config.defaultSort) applySort(config.defaultSort.field, config.defaultSort.direction);
|
|
103
|
+
else if (config.fields.some((f) => f.name === "createdAt")) orderBy.createdAt = "desc";
|
|
104
|
+
else orderBy[config.idField || "id"] = "desc";
|
|
105
|
+
|
|
106
|
+
const include: any = {};
|
|
107
|
+
if (config.include?.length) config.include.forEach((inc) => (include[inc] = true));
|
|
108
|
+
const includeOption = Object.keys(include).length ? { include } : {};
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
where,
|
|
112
|
+
orderBy,
|
|
113
|
+
includeOption,
|
|
114
|
+
skip,
|
|
115
|
+
take,
|
|
116
|
+
safePage,
|
|
117
|
+
safePageSize,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
// getModelName: (e) => getModelName(e, MODEL_MAP),
|
|
16
16
|
// });
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { buildListQuery } from "./lib/query-builder";
|
|
19
|
+
import { prepareMutationData } from "./lib/mutation-builder";
|
|
19
20
|
import type { CrudQueryParams, CrudResponse, EntityConfig } from "../types";
|
|
20
21
|
import { serializeDecimalFields } from "../utils/serialize";
|
|
21
22
|
|
|
@@ -136,163 +137,27 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
136
137
|
return m;
|
|
137
138
|
};
|
|
138
139
|
|
|
139
|
-
const filterValidFields = (data: any, config: EntityConfig) => {
|
|
140
|
-
const valid = new Set(config.fields.filter((f) => !(f as any).isDisplayOnly).map((f) => f.name));
|
|
141
|
-
for (const f of systemFields) valid.add(f);
|
|
142
|
-
const out: Record<string, unknown> = {};
|
|
143
|
-
for (const [k, v] of Object.entries(data)) {
|
|
144
|
-
if (valid.has(k)) out[k] = v;
|
|
145
|
-
else log.warn(`Filtering out invalid field "${k}" for entity "${config.name}"`);
|
|
146
|
-
}
|
|
147
|
-
return out;
|
|
148
|
-
};
|
|
149
140
|
|
|
150
|
-
const castFieldValues = (data: any, config: EntityConfig) => {
|
|
151
|
-
const out = { ...data };
|
|
152
|
-
for (const field of config.fields) {
|
|
153
|
-
const value = out[field.name];
|
|
154
|
-
if (value === undefined || value === null) continue;
|
|
155
|
-
if (field.type === "boolean" || field.type === "switch") {
|
|
156
|
-
let isTrue: boolean;
|
|
157
|
-
if (typeof value === "string") {
|
|
158
|
-
const lv = value.toLowerCase();
|
|
159
|
-
isTrue = lv === "true" || lv === "active" || value === "1" || value === "on";
|
|
160
|
-
} else isTrue = Boolean(value);
|
|
161
|
-
if (field.type === "switch" && field.options && field.options.length >= 2) {
|
|
162
|
-
out[field.name] = isTrue ? (field.options[0] as any).value : (field.options[1] as any).value;
|
|
163
|
-
} else out[field.name] = isTrue;
|
|
164
|
-
} else if (field.type === "number" || (field.type as string) === "integer") {
|
|
165
|
-
if (typeof value === "string") {
|
|
166
|
-
if (value.trim() === "") out[field.name] = null;
|
|
167
|
-
else {
|
|
168
|
-
const num = Number(value);
|
|
169
|
-
if (!isNaN(num)) out[field.name] = num;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
return out;
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
const transformRelationFields = (data: any, mode: "create" | "update") => {
|
|
178
|
-
const out = { ...data };
|
|
179
|
-
// citizenId/targetId là legacy-default từ consumer đầu tiên — app mới khai
|
|
180
|
-
// cột scalar *Id của mình qua deps.relationFieldSkip thay vì sửa core.
|
|
181
|
-
const skip = new Set([
|
|
182
|
-
"id",
|
|
183
|
-
"createdBy",
|
|
184
|
-
"updatedBy",
|
|
185
|
-
"citizenId",
|
|
186
|
-
"targetId",
|
|
187
|
-
...(deps.relationFieldSkip ?? []),
|
|
188
|
-
]);
|
|
189
|
-
for (const key of Object.keys(out)) {
|
|
190
|
-
if (skip.has(key)) continue;
|
|
191
|
-
if (key.endsWith("Id") && key.length > 2) {
|
|
192
|
-
const rel = key.slice(0, -2);
|
|
193
|
-
const value = out[key];
|
|
194
|
-
if (value && typeof value === "string" && value.trim() !== "") {
|
|
195
|
-
out[rel] = { connect: { id: value } };
|
|
196
|
-
delete out[key];
|
|
197
|
-
} else if (value === null || value === undefined || value === "") {
|
|
198
|
-
if (mode === "update") out[rel] = { disconnect: true };
|
|
199
|
-
delete out[key];
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
return out;
|
|
204
|
-
};
|
|
205
141
|
|
|
206
142
|
return {
|
|
207
143
|
async list(entity, config, params) {
|
|
208
144
|
const prismaModel = model(entity);
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
const where: any = { ...(deps.scopeWhere?.(entity, config) ?? {}) };
|
|
227
|
-
if (search && search.trim()) {
|
|
228
|
-
const term = search.trim();
|
|
229
|
-
const searchFields = config.fields.filter((f) => f.filterable && f.type === "text").map((f) => f.name);
|
|
230
|
-
if (searchFields.length) where.OR = searchFields.map((f) => ({ [f]: { contains: term, mode: "insensitive" } }));
|
|
231
|
-
}
|
|
232
|
-
if (filters && filters.length) {
|
|
233
|
-
for (const filter of filters) {
|
|
234
|
-
const { name, value, operator } = filter as any;
|
|
235
|
-
if (value === undefined || value === null || (Array.isArray(value) && value.length === 0)) continue;
|
|
236
|
-
if (!isAllowed(name)) {
|
|
237
|
-
log.warn(`Ignoring filter on disallowed field "${name}" for entity "${entity}"`);
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
let target = where;
|
|
241
|
-
let key = name;
|
|
242
|
-
if (name.includes(".")) {
|
|
243
|
-
const parts = name.split(".");
|
|
244
|
-
key = parts.pop()!;
|
|
245
|
-
for (const p of parts) { if (!target[p]) target[p] = {}; target = target[p]; }
|
|
246
|
-
}
|
|
247
|
-
const op = operator as string;
|
|
248
|
-
if (op === "contains") target[key] = { contains: value, mode: "insensitive" };
|
|
249
|
-
else if (op === "in") target[key] = { in: value };
|
|
250
|
-
else if (op === "notIn") target[key] = { notIn: value };
|
|
251
|
-
else if (op === "eq") target[key] = value;
|
|
252
|
-
else if (op === "ne") target[key] = { not: value };
|
|
253
|
-
else if (op === "gt") target[key] = { gt: value };
|
|
254
|
-
else if (op === "gte") target[key] = { gte: value };
|
|
255
|
-
else if (op === "lt") target[key] = { lt: value };
|
|
256
|
-
else if (op === "lte") target[key] = { lte: value };
|
|
257
|
-
else if (op === "startsWith") target[key] = { startsWith: value, mode: "insensitive" };
|
|
258
|
-
else if (op === "endsWith") target[key] = { endsWith: value, mode: "insensitive" };
|
|
259
|
-
else if (op === "isNull") target[key] = null;
|
|
260
|
-
else if (op === "isNotNull") target[key] = { not: null };
|
|
261
|
-
else target[key] = value;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
// Bộ lọc nâng cao dạng cây ($and/$or, nhiều điều kiện cùng field) —
|
|
265
|
-
// cùng guard isAllowed với filter phẳng; AND vào where (search dùng OR
|
|
266
|
-
// nên không đụng nhau). Cây sai cấu trúc → throw (route trả lỗi 4xx).
|
|
267
|
-
if (params.filterTree) {
|
|
268
|
-
const compiled = compileFilterTree(params.filterTree, {
|
|
269
|
-
isAllowed,
|
|
270
|
-
onDisallowed: (field) =>
|
|
271
|
-
log.warn(`Ignoring filterTree condition on disallowed field "${field}" for entity "${entity}"`),
|
|
272
|
-
});
|
|
273
|
-
if (compiled) {
|
|
274
|
-
where.AND = [...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []), compiled];
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const orderBy: any = {};
|
|
279
|
-
const applySort = (field: string, direction: any) => {
|
|
280
|
-
if (field.includes(".")) {
|
|
281
|
-
const parts = field.split(".");
|
|
282
|
-
const leaf = parts.pop()!;
|
|
283
|
-
let t = orderBy;
|
|
284
|
-
for (const p of parts) { t[p] = t[p] || {}; t = t[p]; }
|
|
285
|
-
t[leaf] = direction;
|
|
286
|
-
} else orderBy[field] = direction;
|
|
287
|
-
};
|
|
288
|
-
if (sort && isAllowed(sort.field)) applySort(sort.field, sort.direction);
|
|
289
|
-
else if (config.defaultSort) applySort(config.defaultSort.field, config.defaultSort.direction);
|
|
290
|
-
else if (config.fields.some((f) => f.name === "createdAt")) orderBy.createdAt = "desc";
|
|
291
|
-
else orderBy[config.idField || "id"] = "desc";
|
|
292
|
-
|
|
293
|
-
const include: any = {};
|
|
294
|
-
if (config.include?.length) config.include.forEach((inc) => (include[inc] = true));
|
|
295
|
-
const includeOption = Object.keys(include).length ? { include } : {};
|
|
145
|
+
|
|
146
|
+
const {
|
|
147
|
+
where,
|
|
148
|
+
orderBy,
|
|
149
|
+
includeOption,
|
|
150
|
+
skip,
|
|
151
|
+
take,
|
|
152
|
+
safePage,
|
|
153
|
+
safePageSize,
|
|
154
|
+
} = buildListQuery({
|
|
155
|
+
entity,
|
|
156
|
+
config,
|
|
157
|
+
params,
|
|
158
|
+
scopeWhere: deps.scopeWhere?.(entity, config) as Record<string, unknown> | undefined,
|
|
159
|
+
onDisallowedFilter: (field) => log.warn(`Ignoring filter on disallowed field "${field}" for entity "${entity}"`),
|
|
160
|
+
});
|
|
296
161
|
|
|
297
162
|
try {
|
|
298
163
|
const [total, data] = await Promise.all([
|
|
@@ -324,13 +189,15 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
324
189
|
async create(entity, data, config, tx) {
|
|
325
190
|
const prismaModel = model(entity, tx ?? prisma);
|
|
326
191
|
try {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
192
|
+
const prismaData = prepareMutationData({
|
|
193
|
+
data,
|
|
194
|
+
config,
|
|
195
|
+
mode: "create",
|
|
196
|
+
systemFields,
|
|
197
|
+
relationFieldSkip: deps.relationFieldSkip,
|
|
198
|
+
logger: log,
|
|
199
|
+
touchUpdatedAtOnCreate: deps.touchUpdatedAtOnCreate,
|
|
200
|
+
});
|
|
334
201
|
return serializeDecimalFields(await prismaModel.create({ data: prismaData }));
|
|
335
202
|
} catch (error) {
|
|
336
203
|
log.error(`Error creating ${entity}:`, error);
|
|
@@ -341,9 +208,14 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
341
208
|
async update(entity, id, data, config, tx) {
|
|
342
209
|
const prismaModel = model(entity, tx ?? prisma);
|
|
343
210
|
try {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
211
|
+
const prismaData = prepareMutationData({
|
|
212
|
+
data,
|
|
213
|
+
config,
|
|
214
|
+
mode: "update",
|
|
215
|
+
systemFields,
|
|
216
|
+
relationFieldSkip: deps.relationFieldSkip,
|
|
217
|
+
logger: log,
|
|
218
|
+
});
|
|
347
219
|
return serializeDecimalFields(await prismaModel.update({ where: { id }, data: prismaData }));
|
|
348
220
|
} catch (error) {
|
|
349
221
|
log.error(`Error updating ${entity}:`, error);
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
Card,
|
|
5
|
-
CardContent,
|
|
6
|
-
CardHeader,
|
|
7
5
|
Badge,
|
|
8
6
|
Button,
|
|
9
7
|
DropdownMenu,
|
|
@@ -13,9 +11,11 @@ import {
|
|
|
13
11
|
Avatar,
|
|
14
12
|
AvatarFallback,
|
|
15
13
|
AvatarImage,
|
|
14
|
+
Separator,
|
|
16
15
|
} from "../../ui";
|
|
17
16
|
import { cn } from "../../utils";
|
|
18
|
-
import {
|
|
17
|
+
import { Phone, MoreHorizontal, Eye, Edit, User, Briefcase, Building2, KeyRound, Mail, ShieldCheck, Copy } from "lucide-react";
|
|
18
|
+
import { toast } from "sonner";
|
|
19
19
|
|
|
20
20
|
interface UsersCardViewProps {
|
|
21
21
|
data: any[];
|
|
@@ -24,31 +24,16 @@ interface UsersCardViewProps {
|
|
|
24
24
|
onViewPermissions?: (user: any) => void;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// Professional Corporate Palette
|
|
27
|
+
// Professional Corporate Palette for Avatars
|
|
28
28
|
const AVATAR_PALETTE = [
|
|
29
|
-
{ bg: "bg-
|
|
30
|
-
{ bg: "bg-emerald-600", text: "text-white" },
|
|
31
|
-
{ bg: "bg-orange-600", text: "text-white" },
|
|
32
|
-
{ bg: "bg-violet-600", text: "text-white" },
|
|
33
|
-
{ bg: "bg-cyan-600", text: "text-white" },
|
|
29
|
+
{ bg: "bg-blue-600", text: "text-white" },
|
|
30
|
+
{ bg: "bg-emerald-600", text: "text-white" },
|
|
31
|
+
{ bg: "bg-orange-600", text: "text-white" },
|
|
32
|
+
{ bg: "bg-violet-600", text: "text-white" },
|
|
33
|
+
{ bg: "bg-cyan-600", text: "text-white" },
|
|
34
|
+
{ bg: "bg-rose-600", text: "text-white" },
|
|
34
35
|
];
|
|
35
36
|
|
|
36
|
-
const ROLE_PALETTE = [
|
|
37
|
-
"bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800",
|
|
38
|
-
"bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800",
|
|
39
|
-
"bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800",
|
|
40
|
-
"bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:border-purple-800",
|
|
41
|
-
"bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-900/30 dark:text-rose-400 dark:border-rose-800",
|
|
42
|
-
];
|
|
43
|
-
|
|
44
|
-
const getRoleStyle = (role: string) => {
|
|
45
|
-
let hash = 0;
|
|
46
|
-
for (let i = 0; i < role.length; i++) {
|
|
47
|
-
hash = role.charCodeAt(i) + ((hash << 5) - hash);
|
|
48
|
-
}
|
|
49
|
-
return ROLE_PALETTE[Math.abs(hash) % ROLE_PALETTE.length];
|
|
50
|
-
};
|
|
51
|
-
|
|
52
37
|
const getAvatarColor = (name: string | null) => {
|
|
53
38
|
if (!name) return AVATAR_PALETTE[0];
|
|
54
39
|
const charCode = name.charCodeAt(0) + (name.charCodeAt(name.length - 1) || 0);
|
|
@@ -62,7 +47,7 @@ export function UsersCardView({
|
|
|
62
47
|
}: UsersCardViewProps) {
|
|
63
48
|
if (!data.length) {
|
|
64
49
|
return (
|
|
65
|
-
<div className="flex flex-col items-center justify-center min-h-[400px] border border-
|
|
50
|
+
<div className="flex flex-col items-center justify-center min-h-[400px] border border-dashed rounded-xl bg-card/50 p-8 text-center">
|
|
66
51
|
<div className="size-16 rounded-full bg-primary/5 flex items-center justify-center mb-4">
|
|
67
52
|
<User className="size-8 text-primary" strokeWidth={1.5} />
|
|
68
53
|
</div>
|
|
@@ -83,189 +68,198 @@ export function UsersCardView({
|
|
|
83
68
|
.map((n) => n[0])
|
|
84
69
|
.join("")
|
|
85
70
|
.toUpperCase()
|
|
86
|
-
.slice(0,
|
|
71
|
+
.slice(0, 2);
|
|
87
72
|
};
|
|
88
73
|
|
|
89
74
|
return (
|
|
90
|
-
<div className="
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
<
|
|
104
|
-
<
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
)}
|
|
75
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5 pb-4 mt-2">
|
|
76
|
+
{data.map((user) => {
|
|
77
|
+
const isActive = user.isActive || user.status === "active";
|
|
78
|
+
const avatarColors = getAvatarColor(user.name);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Card
|
|
82
|
+
key={user.id}
|
|
83
|
+
className="group relative flex flex-col bg-card border-border/60 hover:border-primary/40 hover:shadow-md transition-all duration-300 cursor-pointer overflow-hidden rounded-xl shadow-sm"
|
|
84
|
+
onClick={() => onSelect && onSelect(user)}
|
|
85
|
+
>
|
|
86
|
+
{/* Action Menu (Absolute on Card) */}
|
|
87
|
+
<div className="absolute top-3 right-2 z-10">
|
|
88
|
+
<DropdownMenu modal={false}>
|
|
89
|
+
<DropdownMenuTrigger asChild>
|
|
90
|
+
<Button
|
|
91
|
+
variant="ghost"
|
|
92
|
+
size="icon"
|
|
93
|
+
className="h-7 w-7 rounded-full bg-background hover:bg-muted text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-all duration-200"
|
|
94
|
+
onClick={(e) => e.stopPropagation()}
|
|
110
95
|
>
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
96
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
97
|
+
</Button>
|
|
98
|
+
</DropdownMenuTrigger>
|
|
99
|
+
<DropdownMenuContent align="end" className="w-[160px] rounded-xl shadow-lg">
|
|
100
|
+
<DropdownMenuItem
|
|
101
|
+
onClick={(e) => {
|
|
102
|
+
e.stopPropagation();
|
|
103
|
+
setTimeout(() => onSelect && onSelect(user), 0);
|
|
104
|
+
}}
|
|
105
|
+
className="text-xs cursor-pointer py-2 rounded-md"
|
|
106
|
+
>
|
|
107
|
+
<Eye className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
108
|
+
Xem chi tiết
|
|
109
|
+
</DropdownMenuItem>
|
|
110
|
+
<DropdownMenuItem
|
|
111
|
+
onClick={(e) => {
|
|
112
|
+
e.stopPropagation();
|
|
113
|
+
setTimeout(() => onSelect && onSelect(user), 0);
|
|
114
|
+
}}
|
|
115
|
+
className="text-xs cursor-pointer py-2 rounded-md"
|
|
116
|
+
>
|
|
117
|
+
<Edit className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
118
|
+
Chỉnh sửa
|
|
119
|
+
</DropdownMenuItem>
|
|
120
|
+
{onViewPermissions && (
|
|
121
|
+
<DropdownMenuItem
|
|
122
|
+
onClick={(e) => {
|
|
123
|
+
e.stopPropagation();
|
|
124
|
+
setTimeout(() => onViewPermissions(user), 0);
|
|
125
|
+
}}
|
|
126
|
+
className="text-xs cursor-pointer py-2 rounded-md"
|
|
127
|
+
>
|
|
128
|
+
<KeyRound className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
129
|
+
Quyền hiệu lực
|
|
130
|
+
</DropdownMenuItem>
|
|
131
|
+
)}
|
|
132
|
+
</DropdownMenuContent>
|
|
133
|
+
</DropdownMenu>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="flex flex-col flex-1">
|
|
137
|
+
{/* TOP SECTION: Clean Minimalist Header */}
|
|
138
|
+
<div className="p-4 pt-5 flex items-start gap-3 bg-muted/20 border-b border-border/40">
|
|
139
|
+
|
|
140
|
+
<div className="relative shrink-0 z-10">
|
|
141
|
+
<Avatar className="h-12 w-12 rounded-full border border-border/50 shadow-sm">
|
|
142
|
+
<AvatarImage src={user.image || user.avatar || ""} alt={user.name || ""} className="object-cover rounded-full" />
|
|
143
|
+
<AvatarFallback className={cn("text-xs font-bold rounded-full", avatarColors.bg, avatarColors.text)}>
|
|
144
|
+
{getInitials(user.name || user.email)}
|
|
145
|
+
</AvatarFallback>
|
|
146
|
+
</Avatar>
|
|
147
|
+
<span
|
|
123
148
|
className={cn(
|
|
124
|
-
"absolute -bottom-0.5 -right-0.5
|
|
125
|
-
isActive ? "bg-
|
|
126
|
-
)}
|
|
127
|
-
title={isActive ? "
|
|
149
|
+
"absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full border-2 border-card shadow-sm",
|
|
150
|
+
isActive ? "bg-emerald-500" : "bg-muted-foreground/50"
|
|
151
|
+
)}
|
|
152
|
+
title={isActive ? "Đang hoạt động" : "Đã khóa"}
|
|
128
153
|
/>
|
|
129
154
|
</div>
|
|
130
|
-
|
|
131
|
-
<div className="flex-
|
|
132
|
-
<div className="flex items-center gap-
|
|
133
|
-
<h3
|
|
134
|
-
className="font-semibold text-sm text-foreground truncate leading-tight"
|
|
135
|
-
title={user.name}
|
|
136
|
-
>
|
|
155
|
+
|
|
156
|
+
<div className="flex flex-col min-w-0 flex-1 pt-0.5 z-10">
|
|
157
|
+
<div className="flex items-center gap-1.5 flex-wrap pr-6">
|
|
158
|
+
<h3 className="font-bold text-sm text-foreground leading-tight truncate" title={user.name}>
|
|
137
159
|
{user.name || "Chưa đặt tên"}
|
|
138
160
|
</h3>
|
|
139
|
-
|
|
140
|
-
{/* User Type Badge (Compact) */}
|
|
141
161
|
{user.userType === "customer" && (
|
|
142
|
-
<
|
|
143
|
-
Khách hàng
|
|
144
|
-
</span>
|
|
162
|
+
<Badge className="text-[9px] font-bold bg-emerald-100 text-emerald-700 hover:bg-emerald-200 border-transparent uppercase tracking-widest px-1.5 py-0 h-4 shrink-0 rounded-md">Khách</Badge>
|
|
145
163
|
)}
|
|
146
164
|
{user.userType === "supplier" && (
|
|
147
|
-
<
|
|
148
|
-
Nhà cung cấp
|
|
149
|
-
</span>
|
|
165
|
+
<Badge className="text-[9px] font-bold bg-orange-100 text-orange-700 hover:bg-orange-200 border-transparent uppercase tracking-widest px-1.5 py-0 h-4 shrink-0 rounded-md">NCC</Badge>
|
|
150
166
|
)}
|
|
151
167
|
{(!user.userType || user.userType === "employee") && (
|
|
152
|
-
<
|
|
153
|
-
Nhân viên
|
|
154
|
-
</span>
|
|
168
|
+
<Badge className="text-[9px] font-bold bg-indigo-100 text-indigo-700 hover:bg-indigo-200 border-transparent uppercase tracking-widest px-1.5 py-0 h-4 shrink-0 rounded-md">NV</Badge>
|
|
155
169
|
)}
|
|
156
170
|
</div>
|
|
157
|
-
|
|
158
|
-
<div className="flex items-center gap-1.5
|
|
159
|
-
<span className="truncate
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
variant="ghost"
|
|
170
|
-
className="h-7 w-7 p-0 text-muted-foreground hover:text-primary hover:bg-primary/5 shrink-0 -mt-1 -mr-1 rounded-full"
|
|
171
|
-
onClick={(e) => e.stopPropagation()}
|
|
172
|
-
>
|
|
173
|
-
<MoreHorizontal className="size-4" />
|
|
174
|
-
</Button>
|
|
175
|
-
</DropdownMenuTrigger>
|
|
176
|
-
<DropdownMenuContent align="end" className="w-[140px] rounded-xl border-border p-1.5">
|
|
177
|
-
<DropdownMenuItem
|
|
178
|
-
onSelect={(e) => {
|
|
179
|
-
e.preventDefault();
|
|
180
|
-
setTimeout(() => {
|
|
181
|
-
onSelect && onSelect(user);
|
|
182
|
-
}, 150);
|
|
183
|
-
}}
|
|
184
|
-
className="text-xs font-medium text-foreground cursor-pointer rounded-md py-1.5 focus:bg-primary/5 focus:text-primary"
|
|
185
|
-
>
|
|
186
|
-
<Eye className="mr-2 size-3.5" />
|
|
187
|
-
Xem chi tiết
|
|
188
|
-
</DropdownMenuItem>
|
|
189
|
-
<DropdownMenuItem
|
|
190
|
-
onSelect={(e) => {
|
|
191
|
-
e.preventDefault();
|
|
192
|
-
setTimeout(() => {
|
|
193
|
-
onSelect && onSelect(user);
|
|
194
|
-
}, 150);
|
|
195
|
-
}}
|
|
196
|
-
className="text-xs font-medium text-foreground cursor-pointer rounded-md py-1.5 focus:bg-primary/5 focus:text-primary"
|
|
197
|
-
>
|
|
198
|
-
<Edit className="mr-2 size-3.5" />
|
|
199
|
-
Chỉnh sửa
|
|
200
|
-
</DropdownMenuItem>
|
|
201
|
-
{onViewPermissions && (
|
|
202
|
-
<DropdownMenuItem
|
|
203
|
-
onSelect={(e) => {
|
|
204
|
-
e.preventDefault();
|
|
205
|
-
setTimeout(() => onViewPermissions(user), 0);
|
|
171
|
+
|
|
172
|
+
<div className="flex items-center gap-1.5 mt-1.5 text-muted-foreground w-max max-w-full group/email">
|
|
173
|
+
<span className="text-xs truncate font-medium">{user.email || "—"}</span>
|
|
174
|
+
{user.email && (
|
|
175
|
+
<Button
|
|
176
|
+
variant="ghost"
|
|
177
|
+
size="icon"
|
|
178
|
+
className="h-5 w-5 rounded-md hover:bg-muted shrink-0 text-muted-foreground opacity-60 group-hover/email:opacity-100 transition-opacity"
|
|
179
|
+
onClick={(e) => {
|
|
180
|
+
e.stopPropagation();
|
|
181
|
+
navigator.clipboard.writeText(user.email);
|
|
182
|
+
toast.success("Đã copy Email");
|
|
206
183
|
}}
|
|
207
|
-
|
|
184
|
+
title="Copy email"
|
|
208
185
|
>
|
|
209
|
-
<
|
|
210
|
-
|
|
211
|
-
</DropdownMenuItem>
|
|
186
|
+
<Copy className="w-3 h-3" />
|
|
187
|
+
</Button>
|
|
212
188
|
)}
|
|
213
|
-
</
|
|
214
|
-
</
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
215
191
|
</div>
|
|
216
192
|
|
|
217
|
-
|
|
218
|
-
|
|
193
|
+
<Separator />
|
|
194
|
+
|
|
195
|
+
{/* MIDDLE SECTION: 2-Column Split (Khu vực / Liên hệ) */}
|
|
196
|
+
<div className="px-4 py-3.5 grid grid-cols-2 gap-3 flex-1 items-center bg-card">
|
|
219
197
|
|
|
220
|
-
{/*
|
|
221
|
-
<div className="
|
|
222
|
-
<div className="flex items-center gap-1.5
|
|
223
|
-
<
|
|
224
|
-
<span className="text-
|
|
225
|
-
{user.profiles?.phone || user.phone || "—"}
|
|
226
|
-
</span>
|
|
227
|
-
</div>
|
|
228
|
-
|
|
229
|
-
<div className="flex items-center gap-1.5 min-w-0" title="Trạng thái">
|
|
230
|
-
<ShieldCheck className="size-3 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
|
231
|
-
<span className={cn("text-xs font-medium truncate", isActive ? "text-emerald-600 dark:text-emerald-500" : "text-muted-foreground")}>
|
|
232
|
-
{isActive ? "Đang hoạt động" : "Đã khóa"}
|
|
233
|
-
</span>
|
|
198
|
+
{/* Column 1: Khu vực */}
|
|
199
|
+
<div className="flex flex-col min-w-0 pr-2">
|
|
200
|
+
<div className="flex items-center gap-1.5 mb-1.5">
|
|
201
|
+
<Building2 className="w-3 h-3 text-muted-foreground/70" />
|
|
202
|
+
<span className="text-[9px] text-muted-foreground/80 uppercase font-bold tracking-wider">Khu vực</span>
|
|
234
203
|
</div>
|
|
204
|
+
<span className="text-xs text-foreground/90 font-semibold truncate" title={(user.branchNames && user.branchNames.length > 0) ? user.branchNames.join(", ") : (user.branchName || user.branch?.name || "")}>
|
|
205
|
+
{(user.branchNames && user.branchNames.length > 0)
|
|
206
|
+
? user.branchNames.join(", ")
|
|
207
|
+
: (user.branchName || user.branch?.name || "Chưa có chi nhánh")}
|
|
208
|
+
</span>
|
|
209
|
+
<span className="text-[10px] text-muted-foreground truncate mt-0.5" title={user.departmentName}>
|
|
210
|
+
{user.departmentName || "Chưa phân bổ"}
|
|
211
|
+
</span>
|
|
212
|
+
</div>
|
|
235
213
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
214
|
+
{/* Column 2: Liên hệ */}
|
|
215
|
+
<div className="flex flex-col min-w-0 border-l border-border/50 pl-3">
|
|
216
|
+
<div className="flex items-center gap-1.5 mb-1.5">
|
|
217
|
+
<Phone className="w-3 h-3 text-muted-foreground/70" />
|
|
218
|
+
<span className="text-[9px] text-muted-foreground/80 uppercase font-bold tracking-wider">Liên hệ</span>
|
|
241
219
|
</div>
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
<span className="text-xs text-muted-foreground truncate">
|
|
246
|
-
{(user.branchNames && user.branchNames.length > 0) ? user.branchNames.join(", ") : (user.branchName || user.branch?.name || "—")}
|
|
220
|
+
<div className="flex items-center justify-between gap-1 max-w-full group/phone" onClick={(e) => e.stopPropagation()}>
|
|
221
|
+
<span className="text-xs text-foreground/90 font-semibold truncate">
|
|
222
|
+
{user.profiles?.phone || user.phone || "—"}
|
|
247
223
|
</span>
|
|
224
|
+
{(user.profiles?.phone || user.phone) && (
|
|
225
|
+
<Button
|
|
226
|
+
variant="ghost"
|
|
227
|
+
size="icon"
|
|
228
|
+
className="h-5 w-5 rounded-md hover:bg-muted shrink-0 text-muted-foreground opacity-60 group-hover/phone:opacity-100 transition-opacity"
|
|
229
|
+
onClick={(e) => {
|
|
230
|
+
e.stopPropagation();
|
|
231
|
+
const phone = user.profiles?.phone || user.phone;
|
|
232
|
+
navigator.clipboard.writeText(phone);
|
|
233
|
+
toast.success("Đã copy Số điện thoại");
|
|
234
|
+
}}
|
|
235
|
+
title="Copy số điện thoại"
|
|
236
|
+
>
|
|
237
|
+
<Copy className="w-3 h-3" />
|
|
238
|
+
</Button>
|
|
239
|
+
)}
|
|
248
240
|
</div>
|
|
249
241
|
</div>
|
|
242
|
+
</div>
|
|
250
243
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
244
|
+
<Separator />
|
|
245
|
+
|
|
246
|
+
{/* BOTTOM SECTION: Roles */}
|
|
247
|
+
<div className="px-4 py-3 bg-card border-t border-border/40 shrink-0 h-[44px] flex items-center">
|
|
248
|
+
<div className="flex items-center gap-2 w-full overflow-hidden [mask-image:linear-gradient(to_right,black_85%,transparent_100%)] text-[11px] text-muted-foreground/90 font-medium">
|
|
249
|
+
<ShieldCheck className="w-3.5 h-3.5 shrink-0 text-muted-foreground/50" />
|
|
250
|
+
<span className="truncate">
|
|
251
|
+
{user.roleNames && user.roleNames.length > 0 ? (
|
|
252
|
+
user.roleNames.join(" • ")
|
|
253
|
+
) : (
|
|
254
|
+
<span className="italic opacity-60">Chưa phân vai trò</span>
|
|
255
|
+
)}
|
|
256
|
+
</span>
|
|
257
|
+
</div>
|
|
264
258
|
</div>
|
|
265
|
-
</
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
259
|
+
</div>
|
|
260
|
+
</Card>
|
|
261
|
+
);
|
|
262
|
+
})}
|
|
269
263
|
</div>
|
|
270
264
|
);
|
|
271
265
|
}
|
|
@@ -13,9 +13,13 @@ import {
|
|
|
13
13
|
TableRow,
|
|
14
14
|
Badge,
|
|
15
15
|
Button,
|
|
16
|
+
Avatar,
|
|
17
|
+
AvatarFallback,
|
|
18
|
+
AvatarImage,
|
|
16
19
|
} from "../../ui";
|
|
17
|
-
import { Edit2 } from "lucide-react";
|
|
20
|
+
import { Edit2, Phone, Briefcase, Building2 } from "lucide-react";
|
|
18
21
|
import type { EntityConfig, CrudPermissions } from "../../types";
|
|
22
|
+
import { cn } from "../../utils";
|
|
19
23
|
|
|
20
24
|
import { UserToolbar } from "../components/user-toolbar";
|
|
21
25
|
import { UserStats } from "../components/user-stats";
|
|
@@ -274,136 +278,197 @@ function BasicUserTable({
|
|
|
274
278
|
const [currentPage, setCurrentPage] = useState(1);
|
|
275
279
|
const pageSize = 10;
|
|
276
280
|
|
|
277
|
-
if (!data.length) return
|
|
281
|
+
if (!data.length) return (
|
|
282
|
+
<div className="p-8 text-center bg-card flex flex-col items-center justify-center border-t border-border">
|
|
283
|
+
<p className="text-sm font-medium text-muted-foreground">Không có dữ liệu</p>
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
278
286
|
|
|
279
287
|
const totalPages = Math.ceil(data.length / pageSize);
|
|
280
288
|
const paginatedData = data.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
|
281
289
|
|
|
290
|
+
const getInitials = (name: string | null) => {
|
|
291
|
+
if (!name) return "U";
|
|
292
|
+
return name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// Corporate Palette for Avatars
|
|
296
|
+
const AVATAR_PALETTE = [
|
|
297
|
+
{ bg: "bg-blue-600", text: "text-white" },
|
|
298
|
+
{ bg: "bg-emerald-600", text: "text-white" },
|
|
299
|
+
{ bg: "bg-orange-600", text: "text-white" },
|
|
300
|
+
{ bg: "bg-violet-600", text: "text-white" },
|
|
301
|
+
{ bg: "bg-cyan-600", text: "text-white" },
|
|
302
|
+
{ bg: "bg-rose-600", text: "text-white" },
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
const getAvatarColor = (name: string | null) => {
|
|
306
|
+
if (!name) return AVATAR_PALETTE[0];
|
|
307
|
+
const charCode = name.charCodeAt(0) + (name.charCodeAt(name.length - 1) || 0);
|
|
308
|
+
return AVATAR_PALETTE[charCode % AVATAR_PALETTE.length];
|
|
309
|
+
};
|
|
310
|
+
|
|
282
311
|
return (
|
|
283
|
-
<div className="flex flex-col
|
|
284
|
-
<div className="bg-
|
|
285
|
-
<Table>
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
<
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
312
|
+
<div className="flex flex-col border border-border rounded-lg overflow-hidden shadow-sm mt-2">
|
|
313
|
+
<div className="bg-card w-full overflow-x-auto">
|
|
314
|
+
<Table className="w-full">
|
|
315
|
+
<TableHeader>
|
|
316
|
+
<TableRow className="border-b border-border bg-muted/50 hover:bg-muted/50">
|
|
317
|
+
<TableHead className="w-[50px] font-semibold text-muted-foreground text-xs uppercase tracking-wider text-center h-11">STT</TableHead>
|
|
318
|
+
<TableHead className="w-[30%] min-w-[250px] font-semibold text-muted-foreground text-xs uppercase tracking-wider h-11">Người dùng</TableHead>
|
|
319
|
+
<TableHead className="w-[15%] min-w-[150px] font-semibold text-muted-foreground text-xs uppercase tracking-wider h-11">Liên hệ</TableHead>
|
|
320
|
+
<TableHead className="w-[20%] min-w-[180px] font-semibold text-muted-foreground text-xs uppercase tracking-wider h-11">Phòng ban / Chi nhánh</TableHead>
|
|
321
|
+
<TableHead className="w-[25%] min-w-[200px] font-semibold text-muted-foreground text-xs uppercase tracking-wider h-11">Vai trò</TableHead>
|
|
322
|
+
<TableHead className="w-[60px] text-right h-11"></TableHead>
|
|
323
|
+
</TableRow>
|
|
324
|
+
</TableHeader>
|
|
325
|
+
<TableBody>
|
|
326
|
+
{paginatedData.map((user, index) => {
|
|
327
|
+
const isActive = user.isActive || user.status === "active";
|
|
328
|
+
const avatarColors = getAvatarColor(user.name);
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<TableRow key={user.id} className="border-b border-border/40 hover:bg-accent/10 transition-colors group">
|
|
332
|
+
{/* STT */}
|
|
333
|
+
<TableCell className="py-3 text-center text-muted-foreground text-xs font-medium align-middle">
|
|
334
|
+
{(currentPage - 1) * pageSize + index + 1}
|
|
335
|
+
</TableCell>
|
|
336
|
+
|
|
337
|
+
{/* User Info (Avatar + Name + Type + Status) */}
|
|
338
|
+
<TableCell className="py-3 align-top">
|
|
339
|
+
<div className="flex items-start gap-3">
|
|
340
|
+
<div className="relative shrink-0 mt-0.5">
|
|
341
|
+
<Avatar className="h-10 w-10 rounded-full border shadow-sm">
|
|
342
|
+
<AvatarImage src={user.image || user.avatar || ""} alt={user.name || ""} className="object-cover rounded-full" />
|
|
343
|
+
<AvatarFallback className={cn("text-xs font-bold rounded-full", avatarColors.bg, avatarColors.text)}>
|
|
344
|
+
{getInitials(user.name || user.email)}
|
|
345
|
+
</AvatarFallback>
|
|
346
|
+
</Avatar>
|
|
347
|
+
{/* Note: Status indicator is now a badge next to the name */}
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
<div className="flex flex-col min-w-0">
|
|
351
|
+
<div className="flex items-center gap-1.5 mb-1 flex-wrap">
|
|
352
|
+
<span className="font-bold text-sm text-foreground truncate mr-1">{user.name || "Chưa đặt tên"}</span>
|
|
353
|
+
|
|
354
|
+
{/* Status Badge */}
|
|
355
|
+
{isActive ? (
|
|
356
|
+
<Badge className="text-[9px] h-4 px-1.5 font-bold bg-emerald-100 text-emerald-700 hover:bg-emerald-200 border-transparent uppercase tracking-widest shrink-0 rounded-full">
|
|
357
|
+
<div className="w-1.5 h-1.5 rounded-full bg-emerald-600 mr-1.5" />
|
|
358
|
+
Hoạt động
|
|
359
|
+
</Badge>
|
|
360
|
+
) : (
|
|
361
|
+
<Badge className="text-[9px] h-4 px-1.5 font-bold bg-slate-100 text-slate-600 hover:bg-slate-200 border-transparent uppercase tracking-widest shrink-0 rounded-full">
|
|
362
|
+
<div className="w-1.5 h-1.5 rounded-full bg-slate-400 mr-1.5" />
|
|
363
|
+
Đã khóa
|
|
364
|
+
</Badge>
|
|
365
|
+
)}
|
|
366
|
+
|
|
367
|
+
{/* Type Badge */}
|
|
368
|
+
{user.userType === "customer" && <Badge className="text-[9px] h-4 px-1.5 font-bold bg-emerald-100 text-emerald-700 hover:bg-emerald-200 border-transparent uppercase tracking-widest shrink-0 rounded-full">Khách hàng</Badge>}
|
|
369
|
+
{user.userType === "supplier" && <Badge className="text-[9px] h-4 px-1.5 font-bold bg-orange-100 text-orange-700 hover:bg-orange-200 border-transparent uppercase tracking-widest shrink-0 rounded-full">Nhà cung cấp</Badge>}
|
|
370
|
+
{(!user.userType || user.userType === "employee") && <Badge className="text-[9px] h-4 px-1.5 font-bold bg-indigo-100 text-indigo-700 hover:bg-indigo-200 border-transparent uppercase tracking-widest shrink-0 rounded-full">Nhân viên</Badge>}
|
|
371
|
+
</div>
|
|
372
|
+
<span className="text-xs text-muted-foreground truncate font-medium">{user.email || "—"}</span>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
</TableCell>
|
|
376
|
+
|
|
377
|
+
{/* Contact */}
|
|
378
|
+
<TableCell className="py-3 align-top">
|
|
379
|
+
<div className="flex items-center gap-1.5 mt-1">
|
|
380
|
+
<Phone className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
381
|
+
<span className="text-sm font-medium text-foreground/80">{user.profiles?.phone || user.phone || "—"}</span>
|
|
382
|
+
</div>
|
|
383
|
+
</TableCell>
|
|
384
|
+
|
|
385
|
+
{/* Department & Branch */}
|
|
386
|
+
<TableCell className="py-3 align-top">
|
|
387
|
+
<div className="flex flex-col gap-1.5 mt-1">
|
|
388
|
+
<div className="flex items-center gap-1.5">
|
|
389
|
+
<Briefcase className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
390
|
+
<span className="text-sm font-semibold text-foreground/80 truncate max-w-[200px]" title={user.departmentName}>{user.departmentName || "—"}</span>
|
|
391
|
+
</div>
|
|
392
|
+
<div className="flex items-center gap-1.5">
|
|
393
|
+
<Building2 className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
394
|
+
<span className="text-xs text-muted-foreground truncate max-w-[200px]" title={(user.branchNames && user.branchNames.length > 0) ? user.branchNames.join(", ") : (user.branchName || user.branch?.name || "")}>
|
|
395
|
+
{(user.branchNames && user.branchNames.length > 0) ? user.branchNames.join(", ") : (user.branchName || user.branch?.name || "—")}
|
|
396
|
+
</span>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
</TableCell>
|
|
400
|
+
|
|
401
|
+
{/* Roles */}
|
|
402
|
+
<TableCell className="py-3 align-top">
|
|
403
|
+
<div className="flex flex-wrap gap-1 mt-0.5">
|
|
404
|
+
{user.roleNames && user.roleNames.length > 0 ? (
|
|
405
|
+
user.roleNames.map((r: string, i: number) => (
|
|
406
|
+
<Badge
|
|
407
|
+
key={i}
|
|
408
|
+
className="text-[10px] px-2 py-0.5 font-medium bg-slate-100 text-slate-700 border-transparent hover:bg-slate-200 truncate max-w-[140px]"
|
|
409
|
+
title={r}
|
|
410
|
+
>
|
|
411
|
+
{r}
|
|
412
|
+
</Badge>
|
|
413
|
+
))
|
|
414
|
+
) : (
|
|
415
|
+
<span className="text-xs text-muted-foreground italic">—</span>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
</TableCell>
|
|
419
|
+
|
|
420
|
+
{/* Actions */}
|
|
421
|
+
<TableCell className="py-3 text-right align-middle">
|
|
422
|
+
<Button
|
|
423
|
+
variant="ghost"
|
|
424
|
+
size="icon"
|
|
425
|
+
className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-background shadow-sm opacity-0 group-hover:opacity-100 transition-all focus:opacity-100"
|
|
426
|
+
onClick={(e: React.MouseEvent) => {
|
|
427
|
+
e.stopPropagation();
|
|
428
|
+
onEdit(user);
|
|
429
|
+
}}
|
|
430
|
+
>
|
|
431
|
+
<Edit2 className="h-4 w-4" />
|
|
432
|
+
</Button>
|
|
433
|
+
</TableCell>
|
|
434
|
+
</TableRow>
|
|
435
|
+
);
|
|
436
|
+
})}
|
|
437
|
+
</TableBody>
|
|
438
|
+
</Table>
|
|
439
|
+
</div>
|
|
375
440
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
441
|
+
{/* Pagination */}
|
|
442
|
+
{totalPages > 1 && (
|
|
443
|
+
<div className="flex items-center justify-between px-4 py-3 bg-muted/20 border-t border-border">
|
|
444
|
+
<p className="text-xs text-muted-foreground font-medium">
|
|
445
|
+
Hiển thị từ <span className="font-bold text-foreground">{(currentPage - 1) * pageSize + 1}</span> đến <span className="font-bold text-foreground">{Math.min(currentPage * pageSize, data.length)}</span> / <span className="font-bold text-foreground">{data.length}</span>
|
|
446
|
+
</p>
|
|
447
|
+
<div className="flex items-center gap-1.5">
|
|
448
|
+
<Button
|
|
449
|
+
variant="outline"
|
|
450
|
+
size="sm"
|
|
451
|
+
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
452
|
+
disabled={currentPage === 1}
|
|
453
|
+
className="h-7 text-xs px-2.5 rounded-full bg-card"
|
|
454
|
+
>
|
|
455
|
+
Trang trước
|
|
456
|
+
</Button>
|
|
457
|
+
<div className="text-xs font-semibold text-foreground px-2">
|
|
458
|
+
{currentPage} / {totalPages}
|
|
459
|
+
</div>
|
|
460
|
+
<Button
|
|
461
|
+
variant="outline"
|
|
462
|
+
size="sm"
|
|
463
|
+
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
464
|
+
disabled={currentPage === totalPages}
|
|
465
|
+
className="h-7 text-xs px-2.5 rounded-full bg-card"
|
|
466
|
+
>
|
|
467
|
+
Trang sau
|
|
468
|
+
</Button>
|
|
394
469
|
</div>
|
|
395
|
-
<Button
|
|
396
|
-
variant="outline"
|
|
397
|
-
size="sm"
|
|
398
|
-
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
399
|
-
disabled={currentPage === totalPages}
|
|
400
|
-
className="h-8 rounded-full"
|
|
401
|
-
>
|
|
402
|
-
Trang sau
|
|
403
|
-
</Button>
|
|
404
470
|
</div>
|
|
405
|
-
|
|
406
|
-
)}
|
|
471
|
+
)}
|
|
407
472
|
</div>
|
|
408
473
|
);
|
|
409
474
|
}
|