@goplusvn/core 0.1.26 → 0.1.27

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/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.27 — Live permissions + MultiSelect hết tràn badge
4
+
5
+ Cơ chế "phiên bản bộ quyền" (đúc từ vinhhoa): admin sửa vai trò/phân quyền →
6
+ mọi user đang đăng nhập nhận quyền mới trong ≤60s (tức thì khi focus lại tab).
7
+ - `@goerp/core/rbac/permissions-version`: `getPermissionsVersion(db)` /
8
+ `bumpPermissionsVersion(db)` — lưu SystemConfig key `rbac-permissions-version`,
9
+ schema-tolerant (thử cột audit createdBy/updatedAt/updatedBy → fallback tối
10
+ giản), best-effort không bao giờ throw.
11
+ - Route-handlers factory TỰ bump sau POST/PUT/DELETE role; thêm
12
+ `createPermissionsVersionHandler({ prisma, getSession })` cho endpoint
13
+ GET /api/rbac/permissions-version.
14
+ - `<PermissionsVersionWatcher />` (@goerp/core/ui): poll version + focus;
15
+ đổi → clear tab-content-cache + session.update() (jwt trigger="update" →
16
+ app forceRefresh quyền từ DB — vá cả đa instance vì cache in-memory
17
+ per-process) + router.refresh() + toast (tắt bằng silent).
18
+ App cần: mount watcher trong providers, route dùng handler factory, và jwt
19
+ callback xử lý trigger="update" bằng forceRefresh (vinhhoa đã có sẵn).
20
+
21
+ MultiSelect (tham khảo sersavan/shadcn-multi-select-component):
22
+ - Chip trên trigger WRAP xuống dòng mặc định khi chọn nhiều — trigger cao lên,
23
+ KHÔNG tràn ngang đè component bên cạnh; prop mới `singleLine` giữ 1 dòng
24
+ (chip co + cắt overflow) cho toolbar chật.
25
+ - Dropdown TỰ MỞ LÊN TRÊN khi sát đáy viewport (trước luôn mở xuống → bị cắt).
26
+ - Backspace khi ô tìm kiếm rỗng = bỏ chip cuối (chuẩn combobox).
27
+ - Props mới: `closeOnSelect` (đóng sau mỗi lần chọn), `deduplicateOptions`
28
+ (lọc option trùng value), `chipColor: "colorful" | "neutral"` (chip trầm
29
+ secondary cho form nghiêm túc; mặc định giữ bảng màu xoay vòng).
30
+ - Hardening: container `min-w-0 max-w-full`, trigger `overflow-hidden`,
31
+ chip max-w 8.5rem. Lưu ý chỗ dùng trong flex-row: ô bọc cần `min-w-0`.
32
+
3
33
  ## 0.1.26 — Bộ 3 trang RBAC redesign hoàn chỉnh (bar navy + thẻ + full width)
4
34
 
5
35
  Thiết kế duyệt qua preview vinhhoa, đồng bộ ngôn ngữ trang chi tiết đơn bán hàng:
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.26",
4
+ "version": "0.1.27",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -38,6 +38,7 @@
38
38
  "./styles/*": "./src/styles/*",
39
39
  "./auth/proxy-gate": "./src/auth/proxy-gate.ts",
40
40
  "./rbac/route-handlers": "./src/rbac/route-handlers.ts",
41
+ "./rbac/permissions-version": "./src/rbac/permissions-version.ts",
41
42
  "./ui/shared/table-styles": "./src/ui/shared/table-styles.ts",
42
43
  "./errors/app-error": "./src/errors/app-error.ts",
43
44
  "./errors/error-handler": "./src/errors/error-handler.ts",
package/src/rbac/index.ts CHANGED
@@ -131,6 +131,7 @@ export {
131
131
  isAdmin,
132
132
  } from "../auth";
133
133
 
134
+ export * from "./permissions-version";
134
135
  export * from "./permission-service";
135
136
  export * from "./role-service";
136
137
  export * from "./resource-service";
@@ -0,0 +1,84 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // "Phiên bản bộ quyền" toàn hệ thống — lưu trong bảng SystemConfig của app
3
+ // (DI prisma, schema-tolerant). Giải quyết bài toán: admin sửa vai trò/phân
4
+ // quyền nhưng user đang đăng nhập không nhận quyền mới (session client không
5
+ // refetch, tab-content-cache giữ UI cũ, cache quyền in-memory không lan qua
6
+ // replica). Client mount <PermissionsVersionWatcher /> (từ @goerp/core/ui)
7
+ // poll version; đổi → clear tab cache + session.update() (forceRefresh quyền
8
+ // từ DB) + router.refresh() → quyền áp trong ≤ poll interval, không cần
9
+ // re-login.
10
+ //
11
+ // Bump được gọi TỰ ĐỘNG trong route-handlers factory (POST/PUT/DELETE role);
12
+ // app có route tự viết (vd vinhhoa) thì gọi bumpPermissionsVersion(db) ở mọi
13
+ // điểm ghi RBAC (cập nhật/xóa vai trò, gán vai trò cho user).
14
+
15
+ const DEFAULT_KEY = "rbac-permissions-version";
16
+
17
+ export interface PermissionsVersionOptions {
18
+ /** SystemConfig key — mặc định "rbac-permissions-version". */
19
+ key?: string;
20
+ /** Giá trị ghi vào createdBy/updatedBy nếu schema SystemConfig yêu cầu. */
21
+ auditUser?: string;
22
+ }
23
+
24
+ export async function getPermissionsVersion(
25
+ db: any,
26
+ opts?: PermissionsVersionOptions,
27
+ ): Promise<string> {
28
+ const key = opts?.key ?? DEFAULT_KEY;
29
+ try {
30
+ const row = await db.systemConfig.findUnique({
31
+ where: { key },
32
+ select: { value: true },
33
+ });
34
+ return row?.value ?? "0";
35
+ } catch {
36
+ // App không có bảng systemConfig → coi như version tĩnh (watcher no-op)
37
+ return "0";
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Bump version (best-effort, KHÔNG BAO GIỜ throw — không được làm fail
43
+ * request ghi RBAC chính). Schema-tolerant: thử create/update đầy đủ cột
44
+ * audit (createdBy/updatedAt/updatedBy — vd vinhhoa yêu cầu NOT NULL),
45
+ * thất bại thì thử bản tối giản (schema không có các cột đó).
46
+ */
47
+ export async function bumpPermissionsVersion(
48
+ db: any,
49
+ opts?: PermissionsVersionOptions,
50
+ ): Promise<void> {
51
+ const key = opts?.key ?? DEFAULT_KEY;
52
+ const v = Date.now().toString();
53
+ const now = new Date();
54
+ const auditUser = opts?.auditUser ?? "system";
55
+ const description =
56
+ "Phiên bản bộ quyền RBAC — tự bump khi vai trò/phân quyền thay đổi để client đang đăng nhập tự làm mới session";
57
+
58
+ try {
59
+ await db.systemConfig.upsert({
60
+ where: { key },
61
+ update: { value: v, updatedAt: now, updatedBy: auditUser },
62
+ create: {
63
+ key,
64
+ value: v,
65
+ type: "string",
66
+ category: "rbac",
67
+ description,
68
+ createdBy: auditUser,
69
+ updatedAt: now,
70
+ updatedBy: auditUser,
71
+ },
72
+ });
73
+ } catch {
74
+ try {
75
+ await db.systemConfig.upsert({
76
+ where: { key },
77
+ update: { value: v },
78
+ create: { key, value: v },
79
+ });
80
+ } catch (e) {
81
+ console.warn("[rbac] bumpPermissionsVersion failed (bỏ qua):", e);
82
+ }
83
+ }
84
+ }
@@ -14,6 +14,10 @@
14
14
  // });
15
15
 
16
16
  import { getRolesData, type RoleServiceSchema } from "./role-service";
17
+ import {
18
+ bumpPermissionsVersion,
19
+ getPermissionsVersion,
20
+ } from "./permissions-version";
17
21
 
18
22
  type MaybePromise<T> = T | Promise<T>;
19
23
 
@@ -88,6 +92,7 @@ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
88
92
  await writePermissions(tx, created.code, permissions);
89
93
  return created;
90
94
  });
95
+ await bumpPermissionsVersion(prisma);
91
96
  return json({ id: role.id, code: role.code, name: role.name }, 201);
92
97
  } catch (e) {
93
98
  return fail(e, req);
@@ -151,6 +156,8 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
151
156
  await writePermissions(tx, updated.code, permissions);
152
157
  return updated;
153
158
  });
159
+ // Báo hiệu client đang đăng nhập tự refresh quyền (watcher poll version)
160
+ await bumpPermissionsVersion(prisma);
154
161
  return json({ id: role.id, code: role.code, name: role.name });
155
162
  } catch (e) {
156
163
  return fail(e, req);
@@ -172,6 +179,7 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
172
179
  await tx.rolePermission.deleteMany({ where: { roleCode: role.code } });
173
180
  await tx.role.delete({ where: { id } });
174
181
  });
182
+ await bumpPermissionsVersion(prisma);
175
183
  return new Response(null, { status: 204 });
176
184
  } catch (e) {
177
185
  return fail(e, req);
@@ -180,3 +188,26 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
180
188
 
181
189
  return { GET, PUT, DELETE };
182
190
  }
191
+
192
+ // GET /api/rbac/permissions-version — client watcher poll; chỉ cần đăng nhập.
193
+ export function createPermissionsVersionHandler(deps: {
194
+ prisma: any;
195
+ getSession: () => MaybePromise<any | null>;
196
+ }) {
197
+ const { prisma, getSession } = deps;
198
+
199
+ async function GET() {
200
+ const session = await getSession();
201
+ if (!session?.user?.id) return json({ error: "Unauthorized" }, 401);
202
+ const v = await getPermissionsVersion(prisma);
203
+ return new Response(JSON.stringify({ v }), {
204
+ status: 200,
205
+ headers: {
206
+ "content-type": "application/json",
207
+ "cache-control": "no-store",
208
+ },
209
+ });
210
+ }
211
+
212
+ return { GET };
213
+ }
@@ -46,6 +46,21 @@ interface MultiSelectProps {
46
46
  * (thứ tự đóng băng trong lúc mở — toggle không làm nhảy vị trí).
47
47
  */
48
48
  selectedFirst?: boolean;
49
+ /**
50
+ * Mặc định chip WRAP xuống dòng khi chọn nhiều (trigger cao lên, không tràn
51
+ * ngang — theo pattern sersavan/shadcn-multi-select-component). Đặt true để
52
+ * giữ 1 dòng cố định: chip co + cắt bằng overflow (hợp cho toolbar chật).
53
+ */
54
+ singleLine?: boolean;
55
+ /** Đóng dropdown ngay sau mỗi lần chọn (mặc định giữ mở để chọn tiếp). */
56
+ closeOnSelect?: boolean;
57
+ /** Loại bỏ option trùng value (giữ option đầu tiên). */
58
+ deduplicateOptions?: boolean;
59
+ /**
60
+ * Màu chip trên trigger: "colorful" (xoay vòng bảng màu — mặc định) hoặc
61
+ * "neutral" (secondary trầm, hợp form/report nghiêm túc).
62
+ */
63
+ chipColor?: "colorful" | "neutral";
49
64
  /**
50
65
  * Async mode: nạp options từ server theo từ khoá tìm kiếm (debounce 300ms).
51
66
  * Khi truyền prop này, `options` chỉ dùng làm nguồn label ban đầu (có thể []),
@@ -164,6 +179,10 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
164
179
  disabled = false,
165
180
  responsive = false,
166
181
  selectedFirst = false,
182
+ singleLine = false,
183
+ closeOnSelect = false,
184
+ deduplicateOptions = false,
185
+ chipColor = "colorful",
167
186
  loadOptions,
168
187
  loadingText = "Đang tải...",
169
188
  className,
@@ -175,6 +194,8 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
175
194
  const [internalValue, setInternalValue] =
176
195
  useState<OptionValue[]>(defaultValue);
177
196
  const [open, setOpen] = useState(false);
197
+ // Mở lên trên khi sát đáy viewport (dropdown ~360px bị cắt nếu luôn mở xuống)
198
+ const [dropUp, setDropUp] = useState(false);
178
199
  const [searchValue, setSearchValue] = useState("");
179
200
  const [highlightedIndex, setHighlightedIndex] = useState(-1);
180
201
  // Snapshot selection tại thời điểm MỞ dropdown (cho selectedFirst)
@@ -317,14 +338,27 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
317
338
 
318
339
  // Nguồn danh sách hiển thị (selectedFirst: đóng băng thứ tự theo snapshot lúc mở)
319
340
  const sourceOptions = useMemo(() => {
320
- if (hasAsync) return remoteOptions ?? [];
321
- if (!selectedFirst) return options;
341
+ let base = hasAsync ? (remoteOptions ?? []) : options;
342
+ if (deduplicateOptions) {
343
+ const seen = new Set<OptionValue>();
344
+ base = base.filter((o) =>
345
+ seen.has(o.value) ? false : (seen.add(o.value), true),
346
+ );
347
+ }
348
+ if (hasAsync || !selectedFirst) return base;
322
349
  const snap = new Set(openSnapshot);
323
350
  const sel: MultiSelectOption[] = [];
324
351
  const unsel: MultiSelectOption[] = [];
325
- for (const o of options) (snap.has(o.value) ? sel : unsel).push(o);
352
+ for (const o of base) (snap.has(o.value) ? sel : unsel).push(o);
326
353
  return [...sel, ...unsel];
327
- }, [options, hasAsync, remoteOptions, selectedFirst, openSnapshot]);
354
+ }, [
355
+ options,
356
+ hasAsync,
357
+ remoteOptions,
358
+ selectedFirst,
359
+ openSnapshot,
360
+ deduplicateOptions,
361
+ ]);
328
362
 
329
363
  // Filter options based on search value (async: server đã lọc)
330
364
  const filteredOptions = useMemo(() => {
@@ -483,8 +517,9 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
483
517
  ? value.filter((v) => v !== optionValue)
484
518
  : [...value, optionValue];
485
519
  updateValue(newValue);
520
+ if (closeOnSelect) setOpen(false);
486
521
  },
487
- [value, filteredOptions, updateValue],
522
+ [value, filteredOptions, updateValue, closeOnSelect],
488
523
  );
489
524
 
490
525
  const moveHighlight = useCallback(
@@ -578,7 +613,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
578
613
  );
579
614
 
580
615
  return (
581
- <div ref={containerRef} className="relative w-full">
616
+ <div ref={containerRef} className="relative w-full min-w-0 max-w-full">
582
617
  {/* Screen Reader Live Regions */}
583
618
  <div className="sr-only">
584
619
  <div aria-live="polite" aria-atomic="true" role="status">
@@ -597,18 +632,30 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
597
632
  aria-expanded={open}
598
633
  disabled={disabled}
599
634
  className={cn(
600
- "w-full justify-between h-auto min-h-9 px-3 py-1.5",
635
+ "w-full max-w-full justify-between overflow-hidden h-auto min-h-9 px-3 py-1.5",
601
636
  responsiveSettings.compactMode && "min-h-8 text-sm",
602
637
  className,
603
638
  )}
604
639
  id={id}
605
640
  onClick={(e) => {
606
641
  e.stopPropagation();
607
- if (!open && selectedFirst) setOpenSnapshot([...value]);
642
+ if (!open) {
643
+ if (selectedFirst) setOpenSnapshot([...value]);
644
+ const rect = containerRef.current?.getBoundingClientRect();
645
+ if (rect) {
646
+ const spaceBelow = window.innerHeight - rect.bottom;
647
+ setDropUp(spaceBelow < 360 && rect.top > spaceBelow);
648
+ }
649
+ }
608
650
  setOpen(!open);
609
651
  }}
610
652
  >
611
- <div className="flex min-w-0 flex-nowrap items-center gap-1 overflow-hidden">
653
+ <div
654
+ className={cn(
655
+ "flex min-w-0 max-w-full items-center gap-1",
656
+ singleLine ? "flex-nowrap overflow-hidden" : "flex-wrap",
657
+ )}
658
+ >
612
659
  {value.length === 0 ? (
613
660
  <span className="text-muted-foreground">{placeholder}</span>
614
661
  ) : isAllSelected ? (
@@ -625,8 +672,10 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
625
672
  key={String(opt.value)}
626
673
  variant="secondary"
627
674
  className={cn(
628
- "min-w-0 max-w-[150px] shrink gap-1 pr-1 font-normal",
629
- CHIP_COLORS[chipIndex % CHIP_COLORS.length],
675
+ "min-w-0 max-w-[8.5rem] shrink gap-1 pr-1 font-normal",
676
+ chipColor === "neutral"
677
+ ? "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary"
678
+ : CHIP_COLORS[chipIndex % CHIP_COLORS.length],
630
679
  )}
631
680
  >
632
681
  <span className="truncate">{opt.label}</span>
@@ -681,11 +730,12 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
681
730
  {open && (
682
731
  <div
683
732
  ref={dropdownRef}
684
- className="absolute z-[100] mt-1 w-full min-w-[15rem] rounded-md border bg-popover/95 backdrop-blur-md text-popover-foreground shadow-lg shadow-black/5 animate-in fade-in-0 zoom-in-95 slide-in-from-top-1"
685
- style={{
686
- top: "100%",
687
- left: 0,
688
- }}
733
+ className={cn(
734
+ "absolute left-0 z-[100] w-full min-w-[15rem] rounded-md border bg-popover/95 backdrop-blur-md text-popover-foreground shadow-lg shadow-black/5 animate-in fade-in-0 zoom-in-95",
735
+ dropUp
736
+ ? "bottom-full mb-1 slide-in-from-bottom-1"
737
+ : "top-full mt-1 slide-in-from-top-1",
738
+ )}
689
739
  >
690
740
  <div className="flex flex-col">
691
741
  {/* Search Input */}
@@ -719,6 +769,14 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
719
769
  e.preventDefault();
720
770
  const opt = filteredOptions[highlightedIndex];
721
771
  if (opt && !opt.disabled) handleSelect(opt.value);
772
+ } else if (
773
+ e.key === "Backspace" &&
774
+ searchValue === "" &&
775
+ value.length > 0
776
+ ) {
777
+ // Chuẩn combobox: Backspace khi search rỗng = bỏ chip cuối
778
+ e.preventDefault();
779
+ updateValue(value.slice(0, -1));
722
780
  }
723
781
  }}
724
782
  onClick={(e) => {
@@ -7,3 +7,4 @@ export * from "./table-styles";
7
7
  export * from "./confirm-dialog";
8
8
  export * from "./stat-bar";
9
9
  export * from "./list-toolbar";
10
+ export * from "./permissions-version-watcher";
@@ -0,0 +1,100 @@
1
+ "use client";
2
+
3
+ // Theo dõi "phiên bản bộ quyền" (xem @goerp/core/rbac/permissions-version).
4
+ // Mount 1 lần trong providers của app (BÊN TRONG SessionProvider +
5
+ // TabContentCacheProvider):
6
+ //
7
+ // <PermissionsVersionWatcher />
8
+ //
9
+ // Poll endpoint version mỗi pollMs + khi tab được focus. Khi version đổi:
10
+ // 1. Xóa tab-content-cache (UI đã render với quyền cũ) — giữ tab đang mở.
11
+ // 2. session.update() → NextAuth jwt trigger="update" → app forceRefresh
12
+ // quyền từ DB (vá luôn đa instance: cache in-memory instance khác chưa
13
+ // bị invalidate thì forceRefresh vẫn đọc thẳng DB).
14
+ // 3. router.refresh() → RSC render lại (menu, nút, trang) với session mới.
15
+ // → User đang đăng nhập nhận quyền mới trong ≤ pollMs, không cần re-login.
16
+
17
+ import { useCallback, useEffect, useRef } from "react";
18
+ import { useRouter } from "next/navigation";
19
+ import { useSession } from "next-auth/react";
20
+ import { toast } from "sonner";
21
+
22
+ import { useTabContentCache } from "../layout/tab-content-cache";
23
+
24
+ export interface PermissionsVersionWatcherProps {
25
+ /** Endpoint trả về { v } — mặc định /api/rbac/permissions-version. */
26
+ endpoint?: string;
27
+ /** Chu kỳ poll (ms) — mặc định 60s. */
28
+ pollMs?: number;
29
+ /** Tắt toast thông báo khi quyền đổi. */
30
+ silent?: boolean;
31
+ }
32
+
33
+ export function PermissionsVersionWatcher({
34
+ endpoint = "/api/rbac/permissions-version",
35
+ pollMs = 60_000,
36
+ silent = false,
37
+ }: PermissionsVersionWatcherProps = {}) {
38
+ const { status, update } = useSession();
39
+ const router = useRouter();
40
+ const { clearAllCache } = useTabContentCache();
41
+ const lastSeenRef = useRef<string | null>(null);
42
+ const busyRef = useRef(false);
43
+
44
+ const check = useCallback(async () => {
45
+ if (busyRef.current || document.visibilityState === "hidden") return;
46
+ busyRef.current = true;
47
+ try {
48
+ const res = await fetch(endpoint, { cache: "no-store" });
49
+ if (!res.ok) return;
50
+ const data = (await res.json()) as { v?: string };
51
+ const v = data?.v;
52
+ if (!v) return;
53
+
54
+ if (lastSeenRef.current === null) {
55
+ // Lần đầu sau khi mount: chỉ ghi nhận mốc, không refresh
56
+ lastSeenRef.current = v;
57
+ return;
58
+ }
59
+ if (v === lastSeenRef.current) return;
60
+
61
+ lastSeenRef.current = v;
62
+
63
+ try {
64
+ clearAllCache();
65
+ sessionStorage.removeItem("tab-content-cache");
66
+ } catch {
67
+ // best-effort
68
+ }
69
+ await update(); // jwt trigger="update" → forceRefresh quyền từ DB
70
+ router.refresh();
71
+ if (!silent) {
72
+ toast.info("Quyền truy cập của bạn vừa được cập nhật", {
73
+ description: "Giao diện đã được làm mới theo bộ quyền mới.",
74
+ });
75
+ }
76
+ } catch {
77
+ // Mạng lỗi thì bỏ qua — lần poll sau thử lại
78
+ } finally {
79
+ busyRef.current = false;
80
+ }
81
+ }, [endpoint, silent, clearAllCache, router, update]);
82
+
83
+ useEffect(() => {
84
+ if (status !== "authenticated") return;
85
+
86
+ check();
87
+ const interval = setInterval(check, pollMs);
88
+ const onFocus = () => check();
89
+ window.addEventListener("focus", onFocus);
90
+ document.addEventListener("visibilitychange", onFocus);
91
+
92
+ return () => {
93
+ clearInterval(interval);
94
+ window.removeEventListener("focus", onFocus);
95
+ document.removeEventListener("visibilitychange", onFocus);
96
+ };
97
+ }, [status, pollMs, check]);
98
+
99
+ return null;
100
+ }