---
url: >-
  https://docs.nemo12.com/architecture/sdd-023-family-workspace/member-profile.md
description: >-
  Ảnh thành viên và tên gọi ở nhà, hồ sơ một người kèm scorecard, rồi ba vòng
  feedback SRC-621 tới SRC-624.
---

# SDD-023 · Hồ sơ một người và scorecard

Một phần của [SDD-023](./index.md). Ảnh thành viên và tên gọi ở nhà, hồ sơ một người kèm scorecard, rồi ba vòng feedback SRC-621 tới SRC-624.

## 14. Ảnh thành viên + tên gọi ở nhà (SRC-607)

Vòng feedback 2026-08-26 trên bản chạy thật, bảy sửa đổi:

* **Mỗi thành viên một card riêng**, có ảnh chân dung: ảnh riêng (`photo_media_id`, migration
  0083\) → avatar Google của tài khoản đã liên kết → placeholder chữ cái đầu. Bấm thẳng vào ảnh
  để upload tại chỗ; ảnh bắt buộc là media NỘI BỘ — cùng cổng cứng Q-146 với `family_photos`,
  kiểm ở server vì đây là ảnh người thật, có trẻ em.
* **Bé có "tên gọi ở nhà"** (`learners.nickname`): thứ bố mẹ thật sự gọi con — mentor mở đầu
  câu chuyện bằng tên này thì đúng giọng gia đình. Sửa trong chế độ edit.
* Trang danh sách: tên con xuất hiện **đúng một lần** (chip bấm được; bỏ dòng chữ tĩnh trùng).
* Ghi chú: **nội dung đứng đầu và liền mạch**; ghi chú gắn tag `key` (mục tiêu gia đình) được
  nhấn thêm một bậc chữ. Tag thành siêu dữ liệu: chữ 10px trong dòng meta; bộ sửa tag + Suggest
  tags + Archive **ẩn cho tới khi bấm "edit tags"** — mặc định ghi chú là nội dung, không phải
  bảng điều khiển.
* **Bỏ dòng `→ next_condition`** ("One real conversation, even ten minutes"): ghi chú vận hành
  của journey engine, chiếm một dòng dưới mọi người mà không ai hành động từ đó. StageDot giữ
  nguyên thông tin giai đoạn.

## 15. Hồ sơ một người + scorecard (SRC-620)

Vòng feedback 2026-08-27 trên bản chạy thật. Bốn sửa đổi, và một câu hỏi phải trả lời trước cả bốn: **Dolphin mở màn này ra để làm gì?** Câu trả lời của chủ dự án là *"nhìn vào đó thì biết nên làm gì tiếp"* — nên mọi thứ dưới đây được cân theo đúng câu đó.

### 15.1 Ảnh xuống đáy cột trái

Ảnh thuộc về *"nhà này gồm những ai, trông thế nào"* — cùng loại với các thẻ người ở trên nó. Trước đây nó nằm ở cột phải, dưới dòng ghi chú, tức là ngang hàng với thứ người ta thật sự vào đây để đọc.

### 15.2 Hồ sơ một người (bấm ký hiệu ▤ trên thẻ người)

Cột phải đổi sang hồ sơ người đó: scorecard → bốn danh sách **Pains / Jobs to be done / Needs / Beliefs** → toàn bộ ghi chú về người đó.

* **Đổi cột phải, không mở hộp thoại.** Đây là thứ đọc trước khi gặp gia đình, người ta ở lại vài phút và cuộn. Hộp thoại thì phải đóng mới làm được việc khác. Cột trái vẫn nguyên nên chuyển người là một cú bấm.
* **Ký hiệu riêng, không biến cả thẻ thành nút.** Trong thẻ đã có ba thứ bấm được với ba nghĩa khác nhau (ảnh → upload, StageDot → đổi bước, chế độ sửa → biểu mẫu); một nút bọc ngoài thì mỗi cú bấm hụt mở nhầm màn.
* **Hai tag mới `need` và `belief`** (lần mở rộng đầu tiên của bộ tag đóng — [SDD-024](../sdd-024-dory.md) §2). Không có chúng thì hai trong bốn cột của hồ sơ vĩnh viễn trống.
* **Ghi chú gắn được vào một người lớn** (`family_notes.contact_id`, migration 0160). Trước đó chỉ gắn được vào bé, nên mọi ghi chú về bố mẹ là ghi chú mức cả nhà và hai hồ sơ cạnh nhau hiện y hệt nhau. Việc này cũng **gỡ giới hạn F3** (Audit #005): bố và mẹ nay có dấu vết journey riêng, không còn buộc phải cùng một bước.
* Hồ sơ một người **không trộn ghi chú mức cả nhà**. Trộn vào thì hai hồ sơ cạnh nhau lại giống nhau — đúng cái làm màn này vô dụng. Ghi chú chung vẫn đọc ở dòng thời gian của cả nhà.

### 15.3 Scorecard — 5 cho mỗi người, 4 cho cả nhà

Tính ở backend (`workers/api/src/modules/families/scorecards.ts`, thuần, có test) chứ không ở React: cùng định nghĩa này sẽ còn dùng ở danh sách gia đình và báo cáo tuần, mà định nghĩa nằm trong component thì bản thứ hai chắc chắn lệch bản thứ nhất.

| Thẻ | Đo gì | Vì sao đo như vậy |
| --- | --- | --- |
| **Understanding** | bao nhiêu trong 6 trục `pain`/`jtbd`/`need`/`belief`/`goal`/`fact` đã có ghi chú | Đếm **trục**, không đếm ghi chú. Ba mươi ghi chú toàn `event` thì hồ sơ vẫn rỗng theo nghĩa dùng được; đếm số lượng sẽ thưởng cho việc ghi nhiều, mà ghi nhiều không phải mục tiêu |
| **Journey** | bước hành trình ([SDD-026](../sdd-026-family-journey.md)) quy về thang 100 | Bước **chưa xác nhận** bị trừ 20%: một bước máy suy ra từ số ghi chú không cùng loại với một bước có người ký tên |
| **Reach** / **Account** | người lớn: điện thoại · Zalo · email · đã nối tài khoản. Bé: chỉ "đã có tài khoản chưa" | Dùng chung một công thức thì mọi thẻ của bé đỏ vĩnh viễn vì thiếu một số điện thoại không ai muốn có |
| **Momentum** | số ngày từ ghi chú gần nhất (≤30 xanh, ≥90 mất dấu) | Nhịp gặp hiện là tháng một lần, nên "quá một chu kỳ" là dấu hiệu sớm nhất còn có nghĩa. Sửa ngưỡng khi có nhịp thật đo được, không sửa vì thấy thẻ đỏ nhiều |
| **Evidence** | `fact` / (`fact` + `opinion` + `hypothesis`) | Mẫu số **chỉ gồm** note có nêu độ chắc. Lấy tổng mọi note thì mỗi ghi chép sự kiện lại kéo điểm xuống, và cách để thẻ xanh trở thành **đừng ghi** |

Thẻ cả nhà: Understanding · Journey · Momentum · Inbox (ghi chú chưa gắn tag = nợ), cộng thẻ **Carer** chen lên khi chưa Dolphin nào nhận nhà. Tối đa bốn thẻ hiện ra.

Ba luật giữ hàng thẻ khỏi phình thành bảng điều khiển:

1. **Câu hành động là nội dung, con số chỉ để xếp thứ tự.** Bản phác đầu làm ngược lại — 62% cỡ lớn, câu hành động là dòng chú thích xám — và đọc xong vẫn phải tự nghĩ ra việc, tức là hàng thẻ chưa làm gì cả.
2. **Thẻ cả nhà lấy điểm của người YẾU NHẤT, không lấy trung bình.** Một nhà hiểu bố rất kỹ và chưa biết gì về mẹ ra 50% — trông như "hiểu vừa phải cả hai", trong khi việc cần làm hoàn toàn cụ thể và nằm ở người bị bỏ quên.
3. **Không thẻ nào chấm điểm gia đình.** Đây là công cụ cho Dolphin: thẻ nói việc CỦA CHÚNG TA còn thiếu, không nói gia đình kém.

### 15.4 Ghi chú mặc định chỉ có nội dung

Dòng meta (tác giả · giờ · tag · "edit tags") và ô "Add a reply…" **ẩn cho tới khi bấm** hai ký hiệu ⓘ và ↩. Với mười ghi chú thì trước đây màn hình có mười ô nhập và mười dòng chữ xám mà gần như không lần đọc nào cần tới — nó đọc như một bảng nhập liệu chứ không như một tập ghi chép. Reply **đã có** thì vẫn hiện luôn: đó là nội dung, không phải điều khiển.

### 15.5 Thẻ "cả gia đình" + Follow

Vòng feedback thứ hai cùng ngày. Cột trái nay mở đầu bằng một **thẻ cấp gia đình**, đứng trên mọi thẻ người. Trước đó cấp gia đình không có chỗ đứng nào trên màn hình — chỉ là cái tiêu đề và một nút hình trái tim lẫn giữa các nút ở góc phải — trong khi phần lớn việc của Dolphin là việc với cả nhà.

* **"Follow" thay nút tim**, và **nhiều người theo cùng lúc được**: bảng `family_mentors` vốn đã là quan hệ nhiều-nhiều, chỉ giao diện cũ là chỉ hiện trạng thái của riêng người đang đăng nhập. Nay ai đang theo thì hiện hết (ảnh chồng nhau + số đếm). Biết nhà này đang có ai để hỏi là thứ dùng được ngay; một cái tim chỉ nói về chính mình thì không.
* Thẻ này cũng là **lối về** màn cả nhà khi đang mở hồ sơ một người, cùng ký hiệu ▤ với các thẻ người.
* `GET /v1/mentor/families/{id}` nay trả thêm `carers[]`.

### 15.6 Chip lọc nhỏ hẳn lại

Tám chip cỡ `n12-chip` chiếm hai hàng cao ngang một ghi chú — thanh điều khiển to bằng nội dung, trong khi thứ người ta vào đây để đọc là ghi chú. Nay chúng là chữ 11px cao 24px: vẫn bấm được, không còn tranh chỗ.

### 15.7 Ghi chú có định dạng — và vì sao KHÔNG lưu HTML

Chủ dự án yêu cầu sửa được và tạo mới được ghi chú ở dạng rich text (đậm, xuống dòng). Hiện thực: **lưu Markdown subset, không lưu HTML** (`apps/mentors/src/richText.tsx`).

Lý do là một ràng buộc bảo mật cụ thể, không phải sở thích: một trình soạn thảo `contenteditable` trả về HTML, và lưu HTML nghĩa là ở đâu đó phải có `dangerouslySetInnerHTML` để hiện lại. Trên Dolphin, nội dung do người dùng gõ ra và **mọi mentor đọc được ghi chú của mọi nhà** (Q-141) — một người gõ được `<script>` vào ghi chú thì nó chạy trong phiên của người khác. Cái giá để né là một bộ lọc HTML, mà bộ lọc HTML tự viết là loại mã sai âm thầm và sai lâu.

Markdown subset thì chuỗi lưu xuống vô hại, và hàm hiện dựng ra **phần tử React** chứ không nối chuỗi HTML — không có đường nào cho thẻ lạ đi vào. Hỗ trợ đúng những gì được yêu cầu: `**đậm**`, `*nghiêng*`, xuống dòng, gạch đầu dòng. Ô soạn là `textarea` + thanh ba nút bọc vùng bôi đen; người quen gõ Markdown gõ thẳng được, và dán chữ từ Zalo vào không kéo theo định dạng rác. Ghi chú cũ không có ký hiệu nào thì hiện y nguyên.

Sửa ghi chú: ký hiệu ✎ trong hàng ký hiệu của mỗi ghi chú, `PATCH /v1/mentor/notes/{id}` với `body` (endpoint đã có sẵn).

### 15.8 Một lỗi im lặng đã sửa cùng đợt

Bộ lọc ghi chú gộp nhiều tag luôn lọc **AND**, nên "Goals and motivation" (`goal` + `jtbd`) chỉ khớp ghi chú mang **cả hai** — trên dữ liệu thật là gần như không ghi chú nào. Bộ lọc trông vẫn chạy, chỉ là luôn trả về rỗng, nên không ai báo hỏng. Nay mỗi bộ lọc khai rõ `match: "any" | "all"`, và mọi bộ lọc gộp nhiều tag đều là "hoặc".

## Lịch sử quyết định

### 16. Vòng feedback thứ ba cùng ngày (SRC-621)

#### 16.1 Lưu trữ một người là cái bẫy một chiều — đã sửa

Chủ dự án hỏi *"tự nhiên card của riêng bố đâu rồi?"*. Không phải lỗi hiển thị: trong D1, `family_contacts.con-minh-0` đang ở `status='archived'`, đặt lúc `2026-08-27T11:49:54Z` — tức là một cú bấm nút **Archive** trong chế độ sửa, vài phút sau khi bản mới lên.

Sự thật đáng nói không phải cú bấm nhầm mà là **cái nút đó không hỏi lại, không báo gì, và không có đường quay lại**. Lưu trữ là cách xoá của hệ này (Q-150) — nó bắt buộc phải đảo ngược được, nếu không thì mỗi lần bấm nhầm là một người biến mất khỏi màn hình trong khi dữ liệu vẫn nằm nguyên trong D1. Người dùng không phân biệt được hai chuyện đó; với họ là mất.

Nay cột trái có khối **"Show archived people (n)"** gập lại, mỗi người một nút **Restore**. Khối chỉ xuất hiện khi thật sự có người bị lưu trữ, nên nó không thêm chữ vào màn hình bình thường.

#### 16.2 "NaN people" — hai endpoint, hai hình dạng

Thẻ gia đình hiện `NaN people`. Nguyên nhân: `GET /v1/mentor/families/{id}` trả về **hàng `families` thô**, trong khi `contact_count` / `learner_count` là hai cột do **endpoint danh sách** tự tính bằng subquery. Kiểu `FamilyRow` dùng chung cho cả hai chỗ nên TypeScript không kêu gì, và `undefined + undefined` ra `NaN` hiện thẳng lên màn hình.

Sửa bằng cách đếm từ chính hai mảng đang hiển thị (`contacts` active + `learners`) thay vì tin vào hai cột không có ở endpoint này. Đây cũng là con số **đúng hơn**: nó luôn khớp với số thẻ người mà mắt đang nhìn thấy.

#### 16.3 Bấm cả thẻ, bỏ ký hiệu ▤

Ký hiệu ▤ ở góc bị thay bằng: **cả thẻ là một nút**. Cách hiện thực có một ràng buộc cứng — không bọc bằng `<button>`, vì bên trong thẻ đã có nút, ô nhập và `<label>` tải ảnh, mà HTML cấm lồng nút trong nút (trình duyệt sẽ tự gỡ cấu trúc, mỗi trình duyệt một kiểu). Dùng `div role="button"` kèm `tabIndex` và tự xử lý Enter/Space.

Kèm theo đó, **mọi điều khiển bên trong phải `stopPropagation`**: ảnh (tải lên), StageDot (đổi bước), và các ô sửa. Không chặn thì một cú bấm ra hai việc.

#### 16.4 Bỏ hai chỗ đọc hai lần

* Tiêu đề `<h1>` "Nhà Trí Minh" ở đầu trang: thẻ gia đình ngay dưới mang đúng cái tên đó.
* Dòng "Vũ Huyền Trang · Mother" ở đầu cột giữa: thẻ của chính người đó đang sáng ở cột trái ngay bên cạnh.

#### 16.5 Mentor đang theo — thẻ riêng

Tách khỏi thẻ gia đình vì hai thứ trả lời hai câu khác nhau: thẻ gia đình nói *"nhà này là nhà nào"*, thẻ này nói *"gọi ai thì hỏi được"*. Nhồi chung thì hàng avatar bị ép thành ảnh 24px chú thích bằng tooltip — phải rê chuột từng cái mới biết ai, đúng lúc cần biết nhanh nhất.

#### 16.6 Song ngữ EN–VI (`apps/mentors/src/lang.tsx`)

Công tắc ở góc trên phải, hiện **cả hai chữ** cùng lúc với chữ đang bật được tô đậm — không phải một nút đổi qua đổi lại. Một nút chỉ hiện "VI" thì không nói được nó *đang là* tiếng Việt hay *sẽ đổi sang* tiếng Việt.

**Ngược chiều với `apps/learn/src/lang.tsx`**: ở learn, chuỗi gốc trong mã là tiếng Việt và catalog dịch sang tiếng Anh; ở đây gốc là **tiếng Anh**, vì SRC-586 đã chuyển cả cổng Dolphin sang tiếng Anh và mã hiện có viết bằng tiếng Anh. Đảo chiều gốc sẽ phải sửa từng chuỗi một — đúng loại thay đổi lớn không ai rà hết được.

Hai giới hạn **cố ý**, nói rõ ở đây để không ai tưởng là sót:

| Chưa dịch | Vì sao |
| --- | --- |
| ~~Các tab khác của cổng~~ | **ĐÃ ĐÓNG ở [§21](disclosure-and-albums.md#21-song-ngữ-phủ-cả-cổng-src-627)** |
| ~~**Câu hành động** trên scorecard~~ | **ĐÃ ĐÓNG ở [§20](disclosure-and-albums.md#20-câu-của-scorecard-dịch-được-src-626)** — backend nay trả khoá + tham số |

#### 16.7 Dấu hỏi giải thích bốn cột (`apps/mentors/src/profileLists.ts`)

Bốn nhãn Pain / JTBD / Need / Belief nghe thì rõ, nhưng **ranh giới giữa chúng** mới là chỗ khó — và người ghi sai ranh giới thì bốn danh sách trộn thành một. Hai cặp dễ lẫn nhất: `pain` ↔ `need` (chỗ đau vs thuốc) và `belief` ↔ `opinion` (niềm tin của gia đình vs độ chắc của chính ghi chú).

Mỗi mục vì thế có ba phần, thiếu phần nào cũng hụt:

* **là gì** — một câu định nghĩa;
* **không phải gì** — phần đắt nhất. Định nghĩa suông không tách được hai khái niệm gần nhau, chỉ có ranh giới mới tách được;
* **ví dụ** — câu thật, viết như mentor sẽ gõ, lấy từ ngữ cảnh Nemo12 (bố mẹ Việt, con cấp 2-3), không phải mẫu điền chỗ trống.

Dùng popover chứ không phải tooltip: nội dung là ba đoạn có cấu trúc, mà tooltip mất ngay khi chuột rời đi — không ai đọc xong bốn ví dụ trong lúc phải giữ yên con trỏ. Đóng bằng bấm ra ngoài hoặc phím Esc.

### 17. Sửa dồn về cột giữa, cột trái chỉ để đọc (SRC-622)

#### 17.1 Vì sao dồn

Trước đợt này việc sửa nằm **rải trong thẻ ở cột trái**: một thẻ rộng 22rem phải chứa ảnh bấm-để-tải-lên, ô tên, ô điện thoại, ô email, nút Save và nút Archive. Hai hệ quả:

1. Mọi ô đều hẹp và chồng lên nhau — xem ảnh chụp 2026-08-27 22:30, thẻ của bố cao gấp ba thẻ của mẹ chỉ vì đang mở biểu mẫu.
2. Cột trái vừa là chỗ đọc lướt vừa là biểu mẫu, nên **bấm hụt một cái là mở hộp chọn tệp hoặc lưu trữ mất một người** — đúng chuyện đã xảy ra lúc 11:49 cùng ngày (§16.1).

Nay: **cột trái chỉ hiển thị**. Không ô nhập, không tải ảnh. Cột giữa rộng gấp đôi và vốn đã là chỗ "xem kỹ một người".

Ngoại lệ duy nhất còn ở cột trái là nút **Link account** khi có gợi ý nối tài khoản: nó là một cú bấm *duyệt*, không phải biểu mẫu, và chỉ có nghĩa ngay cạnh người được gợi ý — chuyển sang cột giữa thì phải mở từng người ra mới biết có gợi ý hay không.

#### 17.2 Thẻ hồ sơ một người (cột giữa)

Đứng **trước** scorecard: scorecard nói "làm gì tiếp", mà câu đó chỉ có nghĩa sau khi đã biết đang nói về ai.

Thẻ **đọc được khi không sửa** — nó không phải biểu mẫu ẩn sau công tắc. Những gì đã điền hiện thành chữ và link bấm được; chỉ khi bấm ✎ mới thành ô nhập. Bốn trường mới (migration 0161, trên **cả** `family_contacts` lẫn `learners`):

| Cột | Dùng để |
| --- | --- |
| `facebook_url` | Tách riêng vì gần như mọi phụ huynh Việt đều có — xứng đáng một ô có nhãn sẵn |
| `links_json` | Mảng `[{label, url}]`. LinkedIn, Zalo OA, blog, GitHub, trang công ty… danh sách này còn dài ra, và mỗi lần dài ra mà phải chạy một migration thì nó **sẽ không bao giờ dài ra** — người ta nhét hết vào ô `note` |
| `interests` | Sở thích — thứ Dolphin dùng nhiều nhất để mở lời |
| `expertise` | Lĩnh vực chuyên môn. Với bé nghĩa là môn/mảng em mạnh |

Bốn cột chứ không phải một bảng liên kết riêng (**đã cân nhắc và KHÔNG dựng**): chúng là **thuộc tính** của một người, đọc cùng lúc với người đó và không bao giờ truy vấn độc lập ("tìm mọi người có Facebook" không phải câu hỏi ai hỏi). Bảng thứ hai chỉ đáng tồn tại khi có truy vấn riêng — cùng lý do đã bỏ `family_visits` ở 0080.

Hai chi tiết dễ bỏ sót, cả hai đều là lỗi thật nếu thiếu:

* **Nạp lại nháp khi đổi người** (`useEffect` theo `id`). Thiếu bước này, bấm từ bố sang mẹ sẽ giữ nguyên chữ đang gõ dở của bố trong ô của mẹ — và một cú Save sau đó **ghi đè dữ liệu mẹ bằng dữ liệu bố**.
* **`parseLinks` chịu được dữ liệu hỏng.** `links_json` do người gõ và sẽ được sửa qua nhiều đợt; một chuỗi không phải JSON hợp lệ là chuyện sẽ xảy ra. Ném lỗi thì cả màn hồ sơ trắng vì một ô phụ — trả mảng rỗng thì chỉ mất đúng phần đó.
* Link ra ngoài luôn có `rel="noreferrer"`: URL do người dùng nhập, không để trang đích với tới `window.opener` của cổng nội bộ.

#### 17.3 Bỏ Archive, mọi Save có Cancel

Nút **Archive cạnh nút Save đã bị bỏ**. Nó nằm sát Save nên bấm hụt là một người biến mất — §16.1 là bằng chứng. Lưu trữ nay chỉ còn một đường: khối "archived" ở cột trái, cạnh nút Restore, tức là **cùng chỗ với cách hoàn tác nó**.

Mọi nút Save đều có **Cancel ngay bên cạnh**: thẻ hồ sơ, ô soạn ghi chú mới, và bộ sửa tag (Cancel ở đây trả tag về đúng bản đang lưu, nên bấm lung tung vài chip rồi đổi ý không để lại hậu quả).

*Còn lại một nút "Archive" duy nhất trong màn: nó lưu trữ **một ghi chú**, không phải một người, và nằm sau hai lớp bấm (ⓘ → edit tags). Giữ lại vì đó là đối tượng khác và hiện chưa có yêu cầu bỏ.*

#### 17.4 Trang danh sách: tên đọc được, thẻ cao bằng nhau

* **Tên hiện gần như trọn vẹn.** Trước đây ô tên bị ép `max-w-[5.5rem]` nên "Nguyễn Cảnh Dũng" hiện ra "Nguyễn Cả…" — cắt đúng chỗ làm mất cả họ lẫn tên, tức là mất luôn tác dụng nhận diện mà hàng ảnh-trên-tên-dưới tồn tại để làm. Nay tên tự xuống dòng, chỉ cắt khi **quá 30 ký tự**.
* **Chỗ trống cũng chiếm chỗ.** Nhà chưa khai bố mẹ / con / người chăm vẫn hiện ảnh placeholder viền đứt cộng một dòng tên rỗng, nên mọi thẻ gia đình cao bằng nhau (đo được: 379px cho cả thẻ đầy lẫn thẻ rỗng). Trước đây nhà thiếu người rút lại thành một dòng chữ, khiến lưới hai cột so le và mắt phải đọc lại từng thẻ mới biết mình đang ở đâu. Viền **đứt** chứ không liền: ô đó nói "chỗ này chưa có ai" chứ không giả vờ là một người.

### 18. Dọn thanh công cụ, và một lỗi mất dữ liệu trong im lặng (SRC-623, SRC-624)

#### 18.1 Công tắc ngôn ngữ lên header của cả cổng

Nó đổi **mọi trang**, không riêng trang gia đình, nên chỗ đúng của nó là cạnh avatar trong `App.tsx` — hàng điều khiển cấp tài khoản. Đặt trong thanh công cụ của một màn là nói sai phạm vi của chính nó.

#### 18.2 Bỏ nút ← và công tắc ✎ "sửa gia đình"

* **Nút ←**: tên cổng ở header đã đưa về danh sách gia đình, và trình duyệt có nút Back thật. Một nút quay-lại tự chế luôn thua nút của trình duyệt vì nó không biết người dùng đến đây từ đâu.
* **Công tắc ✎**: sau [§17](#17-sửa-dồn-về-cột-giữa-cột-trái-chỉ-để-đọc-src-622) nó chỉ còn gác ba thứ — nút Link account, nút Restore, và biểu mẫu thêm người — mà cả ba đều **tự nói được mình là hành động**. Một công tắc toàn cục chỉ để mở ba cái nút là một trạng thái thừa người dùng phải nhớ.

Nút thêm ảnh gia đình nay hiện thường trực. Nó là một nút có nhãn rõ ràng, khác hẳn vùng ảnh chân dung bấm-nhầm-được đã bị gỡ khỏi cột trái ở §17.1.

#### 18.3 Thêm người: hai ký hiệu thay hai biểu mẫu thường trực

Trước đây hai biểu mẫu nằm đáy cột trái suốt thời gian bật chế độ sửa, dù thêm người là việc làm một lần rồi thôi. Nay chỉ hai nút; bấm mới mở đúng một biểu mẫu, và mỗi biểu mẫu kết thúc bằng cặp **Save + Cancel**.

"New child" đứng trước vì đó là cái được gọi tên. "New parent" ở lại vì bỏ nó thì **không còn đường nào thêm bố mẹ** — im lặng cắt mất một việc đang dùng được là chuyện khác hẳn với dọn giao diện.

#### 18.4 Thẻ bố/mẹ ba dòng

1. tên (kèm hai ký hiệu trạng thái, vì chúng nói về chính người này)
2. vai trò trong nhà
3. chức danh → nơi làm việc → khu vực (lấy cái đầu tiên có)

Trước đây tên và vai trò chen chung một dòng rồi mọi thứ còn lại dồn vào dòng hai nối bằng dấu chấm giữa — với tên tiếng Việt dài thì vai trò bị đẩy xuống dòng và **trông như một phần của tên**. Dòng ba luôn chiếm chỗ kể cả khi rỗng, để các thẻ không so le (cùng luật đã dùng ở §17.4).

#### 18.5 SRC-624 — hai danh sách lệch nhau, dữ liệu mất trong im lặng

Chủ dự án báo: *"hệ thống đang không lưu được và không hiển thị được thông tin, ví dụ sở thích"*.

**Nguyên nhân.** §17.2 thêm bốn cột vào `CONTACT_FIELDS` — allow-list của `buildUpdate` — nhưng quên thêm vào `ContactBody`, là schema **zod chạy trước nó**. `z.object()` loại bỏ mọi key không khai báo. Hệ quả: API trả **200**, `audit_log` ghi *"đã cập nhật"*, và dữ liệu bị vứt. Không có gì đỏ, không có gì trong log — chỉ có người dùng gõ vào ô, bấm Save, và thấy nó trống lại.

Đây là loại lỗi tệ nhất trong file này: **thành công giả**. Một lỗi 500 thì ai cũng thấy; một 200 kèm mất dữ liệu thì phải có người ngồi kiểm lại từng ô mới phát hiện.

**Sửa.** Bốn trường vào cả hai schema (`ContactBody` và body của PATCH learner).

**Chặn tái phát.** Hai danh sách này sẽ còn lệch nữa — mỗi lần thêm một cột là một cơ hội. Nên chúng được tách sang `contactSchema.ts` (import `routes.ts` trong test thì kéo theo cả router Hono và env của worker), và `rules.test.ts` chốt bất biến: **mọi tên trong `CONTACT_FIELDS` / `LEARNER_FIELDS` đều phải qua được schema mà không bị loại**.

**Một lỗi cùng họ, phát hiện cùng lúc.** `splitLayers` **không bao giờ trả `photo_media_id`**, dù kiểu `ContactLayers.mentor` khai là có. Nghĩa là ảnh chân dung của bố mẹ tải lên xong lưu đúng vào D1 mà giao diện đọc ra `undefined` và luôn hiện chữ cái đầu — hỏng âm thầm từ SRC-607. TypeScript không bắt được vì `splitLayers` trả **kiểu suy ra**, không khai báo kiểu trả về. Đã sửa và có test.
