@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.60",
4
+ "version": "0.1.62",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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 { compileFilterTree } from "./lib/filter-tree";
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
- const { page = 1, pageSize = 10, search, sort, filters } = params;
210
- const safePage = Math.max(1, Number(page) || 1);
211
- const safePageSize = Math.min(Math.max(1, Number(pageSize) || 10), MAX_PAGE_SIZE);
212
- const skip = (safePage - 1) * safePageSize;
213
- const take = safePageSize;
214
-
215
- const allowedFields = new Set<string>([
216
- ...config.fields.map((f) => f.name),
217
- "id", "createdAt", "updatedAt", "createdBy", "updatedBy", config.idField || "id",
218
- ]);
219
- const allowedRelations = new Set<string>(config.include || []);
220
- const isAllowed = (name: string) => {
221
- if (!name) return false;
222
- if (name.includes(".")) return allowedRelations.has(name.split(".")[0]);
223
- return allowedFields.has(name);
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
- let filtered = config ? filterValidFields(data, config) : data;
328
- if (config) filtered = castFieldValues(filtered, config);
329
- if (!filtered.id) filtered.id = crypto.randomUUID();
330
- if (deps.touchUpdatedAtOnCreate && !filtered.updatedAt) {
331
- filtered.updatedAt = new Date();
332
- }
333
- const prismaData = transformRelationFields(filtered, "create");
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
- let filtered = config ? filterValidFields(data, config) : data;
345
- if (config) filtered = castFieldValues(filtered, config);
346
- const prismaData = transformRelationFields(filtered, "update");
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 { Mail, Phone, MoreHorizontal, Eye, Edit, User, Briefcase, Store, ShieldCheck, Hash, Building2 , KeyRound} from "lucide-react";
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-primary", text: "text-primary-foreground" }, // Primary
30
- { bg: "bg-emerald-600", text: "text-white" }, // Emerald
31
- { bg: "bg-orange-600", text: "text-white" }, // Orange
32
- { bg: "bg-violet-600", text: "text-white" }, // Violet
33
- { bg: "bg-cyan-600", text: "text-white" }, // Cyan
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-border rounded-xl bg-card p-8 text-center">
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, 1);
71
+ .slice(0, 2);
87
72
  };
88
73
 
89
74
  return (
90
- <div className="space-y-4">
91
- <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4">
92
- {data.map((user) => {
93
- const isActive = user.isActive || user.status === "active";
94
- const avatarColors = getAvatarColor(user.name);
95
-
96
- return (
97
- <Card
98
- key={user.id}
99
- className="group relative transition-all duration-200 border border-border bg-card cursor-pointer h-full flex flex-col rounded-xl overflow-hidden shadow-sm hover:shadow-md hover:border-primary/30"
100
- onClick={() => onSelect && onSelect(user)}
101
- >
102
- {/* Header */}
103
- <div className="p-3 pb-2 flex gap-2.5 items-start bg-muted border-b border-border">
104
- <div className="relative shrink-0 mt-0.5">
105
- <div
106
- className={cn(
107
- "size-8 rounded-full flex items-center justify-center font-semibold text-xs",
108
- avatarColors.bg, avatarColors.text
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
- {user.image || user.avatar ? (
112
- <img
113
- src={user.image || user.avatar}
114
- alt={user.name}
115
- className="w-full h-full object-cover rounded-full"
116
- />
117
- ) : (
118
- getInitials(user.name || user.email)
119
- )}
120
- </div>
121
- {/* Status Dot */}
122
- <div
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 w-2.5 h-2.5 border-2 border-card rounded-full z-10",
125
- isActive ? "bg-success-semantic" : "bg-text-tertiary"
126
- )}
127
- title={isActive ? "Hoạt động" : "Đã khóa"}
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-1 min-w-0">
132
- <div className="flex items-center gap-2 mb-0.5">
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
- <span className="text-[10px] px-1.5 py-0.5 font-semibold rounded bg-emerald-600 text-white shrink-0">
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
- <span className="text-[10px] px-1.5 py-0.5 font-semibold rounded bg-orange-600 text-white shrink-0">
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
- <span className="text-[10px] px-1.5 py-0.5 font-semibold rounded bg-primary/10 text-primary border border-primary/20 shrink-0">
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 text-xs text-muted-foreground">
159
- <span className="truncate leading-none" title={user.email}>
160
- {user.email || "—"}
161
- </span>
162
- </div>
163
- </div>
164
-
165
- {/* Actions */}
166
- <DropdownMenu modal={false}>
167
- <DropdownMenuTrigger asChild>
168
- <Button
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
- className="rounded-lg text-[13px] cursor-pointer"
184
+ title="Copy email"
208
185
  >
209
- <KeyRound className="size-3.5 mr-2 text-muted-foreground" />
210
- Quyền hiệu lực
211
- </DropdownMenuItem>
186
+ <Copy className="w-3 h-3" />
187
+ </Button>
212
188
  )}
213
- </DropdownMenuContent>
214
- </DropdownMenu>
189
+ </div>
190
+ </div>
215
191
  </div>
216
192
 
217
- {/* Body */}
218
- <div className="p-3 pt-0 flex-1 flex flex-col bg-card">
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
- {/* 2-Column Compact Grid */}
221
- <div className="grid grid-cols-2 gap-x-2 gap-y-1.5 mb-2 mt-2">
222
- <div className="flex items-center gap-1.5 min-w-0" title="Số điện thoại">
223
- <Phone className="size-3 text-muted-foreground shrink-0" strokeWidth={1.5} />
224
- <span className="text-xs text-muted-foreground truncate">
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
- <div className="flex items-center gap-1.5 min-w-0" title="Bộ phận">
237
- <Briefcase className="size-3 text-muted-foreground shrink-0" strokeWidth={1.5} />
238
- <span className="text-xs text-muted-foreground truncate">
239
- {user.departmentName || "—"}
240
- </span>
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
- <div className="flex items-center gap-1.5 min-w-0" title="Chi nhánh">
244
- <Building2 className="size-3 text-muted-foreground shrink-0" strokeWidth={1.5} />
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
- {/* Roles - Compact Format */}
252
- {user.roleNames && user.roleNames.length > 0 && (
253
- <div className="mt-auto pt-2.5 border-t border-border flex flex-wrap gap-1">
254
- {user.roleNames.map((role: string, i: number) => {
255
- const style = getRoleStyle(role);
256
- return (
257
- <span key={i} className={cn("text-[10px] px-1.5 py-0.5 font-medium rounded truncate max-w-[100px] inline-flex items-center border", style)}>
258
- {role}
259
- </span>
260
- );
261
- })}
262
- </div>
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
- </Card>
266
- );
267
- })}
268
- </div>
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 <div className="p-4 text-center">No data</div>;
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 gap-4">
284
- <div className="bg-background">
285
- <Table>
286
- <TableHeader>
287
- <TableRow className="text-sm border-b">
288
- <TableHead className="w-[50px] font-semibold text-foreground text-center h-10">STT</TableHead>
289
- <TableHead className="w-[20%] min-w-[150px] font-semibold text-foreground h-10">Người dùng</TableHead>
290
- <TableHead className="w-[20%] min-w-[150px] font-semibold text-foreground h-10">Liên hệ</TableHead>
291
- <TableHead className="w-[15%] min-w-[120px] font-semibold text-foreground h-10">Trạng thái</TableHead>
292
- <TableHead className="w-[15%] min-w-[120px] font-semibold text-foreground h-10">Phòng ban</TableHead>
293
- <TableHead className="w-[20%] min-w-[150px] font-semibold text-foreground h-10">Vai trò</TableHead>
294
- <TableHead className="w-[50px] text-center h-10"></TableHead>
295
- </TableRow>
296
- </TableHeader>
297
- <TableBody>
298
- {paginatedData.map((user, index) => (
299
- <TableRow key={user.id} className="border-b group">
300
- {/* STT */}
301
- <TableCell className="py-3 text-center text-muted-foreground font-medium text-xs">
302
- {(currentPage - 1) * pageSize + index + 1}
303
- </TableCell>
304
-
305
- {/* User Info */}
306
- <TableCell className="py-3">
307
- <div className="flex flex-col">
308
- <div className="flex items-center gap-2">
309
- <span className="font-semibold text-sm text-foreground">{user.name}</span>
310
- {user.userType === "customer" && <span className="text-xs text-foreground bg-muted border px-2.5 py-0.5 rounded-full font-semibold whitespace-nowrap">Khách hàng</span>}
311
- {user.userType === "supplier" && <span className="text-xs text-background bg-foreground px-2.5 py-0.5 rounded-full font-semibold whitespace-nowrap">Nhà cung cấp</span>}
312
- {(!user.userType || user.userType === "employee") && <span className="text-xs text-foreground bg-muted border px-2.5 py-0.5 rounded-full font-semibold whitespace-nowrap">Nhân viên</span>}
313
- </div>
314
- </div>
315
- </TableCell>
316
-
317
- {/* Contact */}
318
- <TableCell className="py-3">
319
- <div className="flex flex-col">
320
- <span className="text-sm text-foreground">{user.email}</span>
321
- {user.phone && <span className="text-sm text-muted-foreground">{user.phone}</span>}
322
- </div>
323
- </TableCell>
324
-
325
- {/* Status */}
326
- <TableCell className="py-3">
327
- <div className="flex items-center gap-1.5">
328
- <span className={`inline-flex items-center text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap ${
329
- user.isActive || user.status === "active"
330
- ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
331
- : "bg-muted text-muted-foreground"
332
- }`}>
333
- {user.isActive || user.status === "active" ? "Hoạt động" : "Đã khóa"}
334
- </span>
335
- </div>
336
- </TableCell>
337
-
338
- {/* Department */}
339
- <TableCell className="py-3 text-sm text-foreground">
340
- {user.departmentName || ""}
341
- </TableCell>
342
-
343
- {/* Roles */}
344
- <TableCell className="py-3">
345
- <div className="flex flex-wrap gap-1.5">
346
- {user.roleNames?.map((r: string, i: number) => (
347
- <span
348
- key={i}
349
- className="inline-flex items-center bg-muted text-foreground border font-semibold text-xs px-2.5 py-1 rounded-full whitespace-nowrap"
350
- >
351
- {r}
352
- </span>
353
- ))}
354
- </div>
355
- </TableCell>
356
-
357
- {/* Actions */}
358
- <TableCell className="py-2.5 text-right">
359
- <Button
360
- variant="ghost"
361
- size="icon"
362
- onClick={(e: React.MouseEvent) => {
363
- e.stopPropagation();
364
- onEdit(user);
365
- }}
366
- >
367
- <Edit2 className="h-4 w-4" />
368
- </Button>
369
- </TableCell>
370
- </TableRow>
371
- ))}
372
- </TableBody>
373
- </Table>
374
- </div>
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
- {/* Pagination */}
377
- {totalPages > 1 && (
378
- <div className="flex items-center justify-between px-2 pt-2">
379
- <p className="text-sm text-muted-foreground">
380
- Hiển thị từ {(currentPage - 1) * pageSize + 1} đến {Math.min(currentPage * pageSize, data.length)} trong tổng số {data.length}
381
- </p>
382
- <div className="flex items-center gap-2">
383
- <Button
384
- variant="outline"
385
- size="sm"
386
- onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
387
- disabled={currentPage === 1}
388
- className="h-8 rounded-full"
389
- >
390
- Trang trước
391
- </Button>
392
- <div className="text-sm font-semibold text-foreground px-2">
393
- Trang {currentPage} / {totalPages}
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
- </div>
406
- )}
471
+ )}
407
472
  </div>
408
473
  );
409
474
  }