Files
ROLAC/docs/superpowers/plans/2026-05-29-bilingual-dropdown-options.md
T
2026-05-29 21:56:22 -07:00

674 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Bilingual Dropdown Options (English/中文) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make business dropdown options display English and Chinese together as `English/中文` (e.g. `Worship/敬拜`, `Cash/現金`) across the Angular app, without changing stored values or the database.
**Architecture:** Two seams. (1) DB-backed lookups (giving categories, ministries, expense category groups/subs) already carry `name_en` + `name_zh`; their API services compute a display-only `label = bilingual(name_en, name_zh)` at load time and templates bind `textField="label"`. (2) Hard-coded enum dropdowns (payment method, expense status, member status, gender, language, roles) move to a central `shared/i18n/option-lists.ts` of `{value,label}` arrays. A single `bilingual()` helper produces the `英文/中文` string. Values bound into forms/DB are unchanged.
**Tech Stack:** Angular 20 (standalone components), Kendo UI for Angular v20 (DropDownList / MultiSelect), Karma + Jasmine (`ng test`), Tailwind v4 for layout. Spec: `docs/superpowers/specs/2026-05-29-bilingual-dropdown-options-design.md`.
**Conventions to honor:**
- Kendo: whenever `textField`/`valueField` are set against an object array, also set `[valuePrimitive]="true"` so the form binds the scalar value, not the whole object.
- Format: `英文/中文`, no spaces, English first; if no Chinese, show English only.
- Work directly on `main` (repo convention — recent feature work is committed straight to `main`). The working tree already has unrelated modified/deleted files; **stage only the files each task names** — never `git add -A`.
---
## File Structure
**New files**
- `APP/src/app/shared/i18n/bilingual.ts` — the `bilingual(en, zh)` helper. One responsibility: format a bilingual display string.
- `APP/src/app/shared/i18n/bilingual.spec.ts` — unit tests for the helper.
- `APP/src/app/shared/i18n/option-lists.ts` — central `{value,label}` option arrays for all hard-coded enums.
- `APP/src/app/shared/i18n/option-lists.spec.ts` — guards values/shape of the option arrays.
**Modified (DTO / service — compute `label`)**
- `APP/src/app/features/giving/models/giving.model.ts`
- `APP/src/app/features/expense/models/expense.model.ts`
- `APP/src/app/features/giving/services/giving-category-api.service.ts`
- `APP/src/app/features/expense/services/ministry-api.service.ts`
- `APP/src/app/features/expense/services/expense-category-api.service.ts`
**Modified (templates / components — bind labels)**
- `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.{html,ts}`
- `APP/src/app/features/giving/pages/givings-page/givings-page.component.{html,ts}`
- `APP/src/app/features/expense/pages/expenses-page/expenses-page.component.{html,ts}`
- `APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.{html,ts}`
- `APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.{html,ts}`
- `APP/src/app/features/members/pages/members-page/members-page.component.{html,ts}`
- `APP/src/app/features/users/components/create-user-dialog/create-user-dialog.component.{html,ts}`
- `APP/src/app/features/users/components/edit-user-dialog/edit-user-dialog.component.{html,ts}`
- `APP/src/app/features/members/components/create-user-dialog/create-user-dialog.component.{html,ts}`
**Docs**
- `docs/PLANNING.md` — add the dropdown-bilingual convention section.
**Testing note:** The helper and option-lists get real Jasmine unit tests (`npm run test:ci`). The template/wiring tasks are display-only changes verified by a clean `ng build` (type safety) plus the final browser preview pass (Task 10) — the repo has no Karma DOM tests for these Kendo pages, and adding brittle ones is out of scope.
---
## Task 1: `bilingual()` helper
**Files:**
- Create: `APP/src/app/shared/i18n/bilingual.ts`
- Test: `APP/src/app/shared/i18n/bilingual.spec.ts`
- [ ] **Step 1: Write the failing test**
Create `APP/src/app/shared/i18n/bilingual.spec.ts`:
```ts
import { bilingual } from './bilingual';
describe('bilingual', () => {
it('joins English and Chinese with a slash, no spaces', () => {
expect(bilingual('Worship', '敬拜')).toBe('Worship/敬拜');
});
it('returns English only when Chinese is null', () => {
expect(bilingual('Zelle', null)).toBe('Zelle');
});
it('returns English only when Chinese is undefined', () => {
expect(bilingual('PayPal')).toBe('PayPal');
});
it('returns English only when Chinese is an empty string', () => {
expect(bilingual('Other', '')).toBe('Other');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd APP && npm run test:ci`
Expected: FAIL — `Cannot find module './bilingual'` (compilation error).
- [ ] **Step 3: Write minimal implementation**
Create `APP/src/app/shared/i18n/bilingual.ts`:
```ts
/**
* Display-only bilingual label: English first, Chinese second, joined by '/'
* with no spaces. Falls back to English alone when no Chinese is provided.
* Does NOT alter stored values — for display binding only.
*/
export const bilingual = (en: string, zh?: string | null): string =>
zh ? `${en}/${zh}` : en;
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd APP && npm run test:ci`
Expected: PASS (4 specs in the `bilingual` suite).
- [ ] **Step 5: Commit**
```bash
git add APP/src/app/shared/i18n/bilingual.ts APP/src/app/shared/i18n/bilingual.spec.ts
git commit -m "feat(i18n): add bilingual() display helper"
```
---
## Task 2: Central option lists for hard-coded enums
**Files:**
- Create: `APP/src/app/shared/i18n/option-lists.ts`
- Test: `APP/src/app/shared/i18n/option-lists.spec.ts`
- [ ] **Step 1: Write the failing test**
Create `APP/src/app/shared/i18n/option-lists.spec.ts`:
```ts
import {
PAYMENT_METHOD_OPTIONS, EXPENSE_STATUS_OPTIONS, MEMBER_STATUS_OPTIONS,
GENDER_OPTIONS, LANGUAGE_OPTIONS, ROLE_OPTIONS,
} from './option-lists';
describe('option-lists', () => {
it('payment methods preserve raw values and show bilingual labels', () => {
expect(PAYMENT_METHOD_OPTIONS.map(o => o.value))
.toEqual(['Cash', 'Check', 'Zelle', 'PayPal', 'Other']);
expect(PAYMENT_METHOD_OPTIONS.find(o => o.value === 'Cash')!.label).toBe('Cash/現金');
expect(PAYMENT_METHOD_OPTIONS.find(o => o.value === 'Zelle')!.label).toBe('Zelle');
});
it('expense statuses preserve raw enum values', () => {
expect(EXPENSE_STATUS_OPTIONS.map(o => o.value))
.toEqual(['Draft', 'PendingApproval', 'Approved', 'Paid', 'Rejected']);
});
it('member statuses preserve raw values', () => {
expect(MEMBER_STATUS_OPTIONS.map(o => o.value))
.toEqual(['Member', 'Visitor', 'Inactive', 'Former']);
});
it('gender values stay M/F/Other', () => {
expect(GENDER_OPTIONS.map(o => o.value)).toEqual(['M', 'F', 'Other']);
});
it('language values stay en/zh-TW', () => {
expect(LANGUAGE_OPTIONS.map(o => o.value)).toEqual(['en', 'zh-TW']);
});
it('role options cover all 13 role codes', () => {
expect(ROLE_OPTIONS.length).toBe(13);
expect(ROLE_OPTIONS.map(o => o.value)).toContain('super_admin');
expect(ROLE_OPTIONS.map(o => o.value)).toContain('visitor');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd APP && npm run test:ci`
Expected: FAIL — `Cannot find module './option-lists'`.
- [ ] **Step 3: Write minimal implementation**
Create `APP/src/app/shared/i18n/option-lists.ts`:
```ts
/**
* Central bilingual option lists for hard-coded enum dropdowns.
* `value` is the raw value stored/sent to the API (unchanged from before);
* `label` is the display string `英文/中文`. Bind in templates with
* textField="label" valueField="value" [valuePrimitive]="true".
*/
export interface BilingualOption { readonly value: string; readonly label: string; }
export const PAYMENT_METHOD_OPTIONS: readonly BilingualOption[] = [
{ value: 'Cash', label: 'Cash/現金' },
{ value: 'Check', label: 'Check/支票' },
{ value: 'Zelle', label: 'Zelle' },
{ value: 'PayPal', label: 'PayPal' },
{ value: 'Other', label: 'Other/其他' },
];
export const EXPENSE_STATUS_OPTIONS: readonly BilingualOption[] = [
{ value: 'Draft', label: 'Draft/草稿' },
{ value: 'PendingApproval', label: 'PendingApproval/待審核' },
{ value: 'Approved', label: 'Approved/已核准' },
{ value: 'Paid', label: 'Paid/已付款' },
{ value: 'Rejected', label: 'Rejected/已拒絕' },
];
export const MEMBER_STATUS_OPTIONS: readonly BilingualOption[] = [
{ value: 'Member', label: 'Member/會友' },
{ value: 'Visitor', label: 'Visitor/訪客' },
{ value: 'Inactive', label: 'Inactive/未活躍' },
{ value: 'Former', label: 'Former/已離開' },
];
export const GENDER_OPTIONS: readonly BilingualOption[] = [
{ value: 'M', label: 'Male/男' },
{ value: 'F', label: 'Female/女' },
{ value: 'Other', label: 'Other/其他' },
];
export const LANGUAGE_OPTIONS: readonly BilingualOption[] = [
{ value: 'en', label: 'English/英文' },
{ value: 'zh-TW', label: '中文/Chinese' },
];
export const ROLE_OPTIONS: readonly BilingualOption[] = [
{ value: 'super_admin', label: 'Super Admin/系統管理員' },
{ value: 'pastor', label: 'Pastor/牧師' },
{ value: 'board_member', label: 'Board Member/理事' },
{ value: 'coworker_chair', label: 'Coworker Chair/同工會主席' },
{ value: 'ministry_leader', label: 'Ministry Leader/事工領袖' },
{ value: 'district_leader', label: 'District Leader/區長' },
{ value: 'cell_leader', label: 'Cell Leader/小組長' },
{ value: 'coworker', label: 'Coworker/同工' },
{ value: 'finance', label: 'Finance/財務同工' },
{ value: 'secretary', label: 'Secretary/行政秘書' },
{ value: 'worship_leader', label: 'Worship Leader/敬拜領袖' },
{ value: 'member', label: 'Member/一般教友' },
{ value: 'visitor', label: 'Visitor/訪客' },
];
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd APP && npm run test:ci`
Expected: PASS (all `option-lists` specs).
- [ ] **Step 5: Commit**
```bash
git add APP/src/app/shared/i18n/option-lists.ts APP/src/app/shared/i18n/option-lists.spec.ts
git commit -m "feat(i18n): add central bilingual option lists for enum dropdowns"
```
---
## Task 3: DB-backed DTOs gain `label`; services compute it
**Files:**
- Modify: `APP/src/app/features/giving/models/giving.model.ts` (add `label?` to `GivingCategoryDto`)
- Modify: `APP/src/app/features/expense/models/expense.model.ts` (add `label?` to `MinistryDto`, `ExpenseCategoryGroupDto`, `ExpenseSubCategoryDto`)
- Modify: `APP/src/app/features/giving/services/giving-category-api.service.ts`
- Modify: `APP/src/app/features/expense/services/ministry-api.service.ts`
- Modify: `APP/src/app/features/expense/services/expense-category-api.service.ts`
> No unit test here — this is a typed data-shaping change verified by `ng build`. Behavior is exercised end-to-end in Task 10.
- [ ] **Step 1: Add `label?` to the giving DTO**
In `giving.model.ts`, add `label?: string;` to `GivingCategoryDto` (keep the existing `isActive`/`sortOrder` fields — only the `label?` line is new):
```ts
export interface GivingCategoryDto {
id: number;
name_en: string;
name_zh: string | null;
description_en: string | null;
description_zh: string | null;
isActive: boolean;
sortOrder: number;
/** Display-only bilingual label, computed in the API service. */
label?: string;
}
```
- [ ] **Step 2: Add `label?` to the expense DTOs**
In `expense.model.ts`, add `label?: string;` to `MinistryDto`, `ExpenseCategoryGroupDto`, and `ExpenseSubCategoryDto`. Example for `MinistryDto`:
```ts
export interface MinistryDto {
id: number;
name_en: string;
name_zh: string | null;
sortOrder: number;
isActive: boolean;
/** Display-only bilingual label, computed in the API service. */
label?: string;
}
```
Do the same (`label?: string;`) for `ExpenseCategoryGroupDto` and `ExpenseSubCategoryDto`.
- [ ] **Step 3: Compute `label` in `GivingCategoryApiService.getAll`**
In `giving-category-api.service.ts`, the current import is `import { Observable } from 'rxjs';` — change it to `import { Observable, map } from 'rxjs';` and add `import { bilingual } from '../../../shared/i18n/bilingual';`. Then pipe the existing `getAll` (keep the `HttpParams` exactly as-is, just append `.pipe(...)`):
```ts
getAll(includeInactive = false): Observable<GivingCategoryDto[]> {
const params = new HttpParams().set('includeInactive', includeInactive);
return this.http.get<GivingCategoryDto[]>(this.endpoint, { params }).pipe(
map(list => list.map(c => ({ ...c, label: bilingual(c.name_en, c.name_zh) }))),
);
}
```
- [ ] **Step 4: Compute `label` in the ministry service**
Open `ministry-api.service.ts`. Find the method that returns `Observable<MinistryDto[]>` (e.g. `getAll`). Add the same imports (`map` from `rxjs`, `bilingual`) and pipe each item:
```ts
.pipe(map(list => list.map(m => ({ ...m, label: bilingual(m.name_en, m.name_zh) }))))
```
- [ ] **Step 5: Compute `label` for expense category groups AND their sub-categories**
Open `expense-category-api.service.ts`. Find the method returning `Observable<ExpenseCategoryGroupDto[]>` (e.g. `getAll`). Decorate both the group and its nested `subCategories`:
```ts
import { map } from 'rxjs';
import { bilingual } from '../../../shared/i18n/bilingual';
// ...
.pipe(map(groups => groups.map(g => ({
...g,
label: bilingual(g.name_en, g.name_zh),
subCategories: g.subCategories.map(s => ({ ...s, label: bilingual(s.name_en, s.name_zh) })),
}))))
```
If sub-categories are fetched by a separate method/endpoint instead of nested, apply the same `.pipe(map(list => list.map(s => ({ ...s, label: bilingual(s.name_en, s.name_zh) }))))` to that method too.
- [ ] **Step 6: Build to verify types**
Run: `cd APP && npm run build`
Expected: build succeeds with no TypeScript errors.
- [ ] **Step 7: Commit**
```bash
git add APP/src/app/features/giving/models/giving.model.ts APP/src/app/features/expense/models/expense.model.ts APP/src/app/features/giving/services/giving-category-api.service.ts APP/src/app/features/expense/services/ministry-api.service.ts APP/src/app/features/expense/services/expense-category-api.service.ts
git commit -m "feat(i18n): compute bilingual label on giving/ministry/expense-category lookups"
```
---
## Task 4: Offering-session page — Type dropdown + line echo
**Files:**
- Modify: `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html`
- Modify: `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts`
- [ ] **Step 1: Bind the Type dropdown to `label`**
In the HTML, the giving Type dropdown currently reads:
```html
<kendo-dropdownlist [data]="categories" textField="name_en" valueField="id" [valuePrimitive]="true"
[(ngModel)]="entry.givingCategoryId"></kendo-dropdownlist>
```
Change `textField="name_en"` to `textField="label"`. Leave `valueField`, `valuePrimitive`, and `ngModel` unchanged.
- [ ] **Step 2: Convert the Method (payment) dropdown to bilingual options**
The Method dropdown currently binds a bare string array:
```html
<kendo-dropdownlist [data]="paymentMethods" [(ngModel)]="entry.paymentMethod"></kendo-dropdownlist>
```
Change it to use the central options with primitive binding:
```html
<kendo-dropdownlist [data]="paymentMethods" textField="label" valueField="value" [valuePrimitive]="true"
[(ngModel)]="entry.paymentMethod"></kendo-dropdownlist>
```
- [ ] **Step 3: Update the component to use central payment options and bilingual line echo**
In `offering-session-page.component.ts`:
- Add import: `import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists';`
- Replace the field `readonly paymentMethods: PaymentMethod[] = ['Cash', 'Check', 'Zelle', 'PayPal', 'Other'];` with:
```ts
readonly paymentMethods = PAYMENT_METHOD_OPTIONS;
```
- In `addLine()`, change the line-echo from English-only to the bilingual label:
```ts
categoryName: cat?.label ?? '',
```
(was `categoryName: cat?.name_en ?? ''`).
- [ ] **Step 4: Build to verify**
Run: `cd APP && npm run build`
Expected: build succeeds. (`entry.paymentMethod` is still a `PaymentMethod` string; `[valuePrimitive]` binds `value` which is that string.)
- [ ] **Step 5: Commit**
```bash
git add APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts
git commit -m "feat(i18n): bilingual Type/Method dropdowns + line echo on offering session"
```
---
## Task 5: Givings page — Type filter, Type dialog, Method
**Files:**
- Modify: `APP/src/app/features/giving/pages/givings-page/givings-page.component.html`
- Modify: `APP/src/app/features/giving/pages/givings-page/givings-page.component.ts`
- [ ] **Step 1: Bind both category dropdowns to `label`**
In the HTML there are two category dropdowns (a filter near line 9 and a dialog near line 48), both `textField="name_en"`. Change `textField="name_en"` → `textField="label"` on both.
- [ ] **Step 2: Fix the filter `defaultItem` to carry a bilingual label**
The filter dropdown has a `[defaultItem]="{ id: null, name_en: 'All types' }"`. Because `textField` is now `label`, change it to:
```html
[defaultItem]="{ id: null, label: 'All types/全部類型' }"
```
- [ ] **Step 3: Convert the Method dropdown to bilingual options**
Change the payment dropdown from the bare-string form:
```html
<kendo-dropdownlist [data]="paymentMethods" [(ngModel)]="form.paymentMethod"></kendo-dropdownlist>
```
to:
```html
<kendo-dropdownlist [data]="paymentMethods" textField="label" valueField="value" [valuePrimitive]="true"
[(ngModel)]="form.paymentMethod"></kendo-dropdownlist>
```
(Match the existing `[(ngModel)]` target — it may be `form.paymentMethod` or similar; keep whatever the file already binds.)
- [ ] **Step 4: Update the component to use central payment options**
In `givings-page.component.ts`:
- Add import: `import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists';`
- Replace the `readonly paymentMethods: PaymentMethod[] = [...]` field with `readonly paymentMethods = PAYMENT_METHOD_OPTIONS;`
- [ ] **Step 5: Build to verify**
Run: `cd APP && npm run build`
Expected: build succeeds.
- [ ] **Step 6: Commit**
```bash
git add APP/src/app/features/giving/pages/givings-page/givings-page.component.html APP/src/app/features/giving/pages/givings-page/givings-page.component.ts
git commit -m "feat(i18n): bilingual category + method dropdowns on givings page"
```
---
## Task 6: Expense — list filters + form dialog
**Files:**
- Modify: `APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html`
- Modify: `APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts`
- Modify: `APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html`
- [ ] **Step 1: Ministry filter → `label`, fix its `defaultItem`**
In `expenses-page.component.html`, the Ministry filter is `textField="name_en"` with `[defaultItem]="{ id: null, name_en: 'All Ministries' }"`. Change `textField="name_en"` → `textField="label"` and the defaultItem to:
```html
[defaultItem]="{ id: null, label: 'All Ministries/全部事工' }"
```
- [ ] **Step 2: Convert the Status filter to bilingual options**
The Status filter binds a bare string array: `<kendo-dropdownlist [data]="statuses" [(ngModel)]="filter.status" [defaultItem]="null">` (keep the exact `ngModel`/`defaultItem` the file uses). Change it to:
```html
<kendo-dropdownlist [data]="statuses" textField="label" valueField="value" [valuePrimitive]="true"
[(ngModel)]="filter.status" [defaultItem]="{ value: null, label: 'All Status/全部狀態' }"></kendo-dropdownlist>
```
(If the existing code used `[defaultItem]="null"`, replace with the object above so the "all" row still renders a label; the bound value stays `null`.)
- [ ] **Step 3: Update the expenses-page component to central status options**
In `expenses-page.component.ts`:
- Add import: `import { EXPENSE_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';`
- Replace `readonly statuses: ExpenseStatus[] = ['Draft', 'PendingApproval', 'Approved', 'Paid', 'Rejected'];` with `readonly statuses = EXPENSE_STATUS_OPTIONS;`
- [ ] **Step 4: Form dialog — Ministry / Group / Sub dropdowns → `label`**
In `expense-form-dialog.component.html`, three dropdowns use `textField="name_en"` (Ministry ~line 22, Group ~line 34, Sub ~line 47). Change all three to `textField="label"`. For any `defaultItem` placeholders on these (e.g. `{ id: null, name_en: '-- Select ministry --' }`), change the key to `label` with bilingual text:
```html
[defaultItem]="{ id: null, label: '-- Select ministry --/請選擇事工' }"
```
Apply the equivalent bilingual placeholder for the group and sub dropdowns (e.g. `-- Select category --/請選擇大類`, `-- Select sub-category --/請選擇子項`).
- [ ] **Step 5: Build to verify**
Run: `cd APP && npm run build`
Expected: build succeeds. (`filter.status` remains an `ExpenseStatus | null`; primitive binding supplies the `value`.)
- [ ] **Step 6: Commit**
```bash
git add APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html
git commit -m "feat(i18n): bilingual ministry/category/status dropdowns on expense pages"
```
---
## Task 7: Member form + members page — Gender & Status
**Files:**
- Modify: `APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.html`
- Modify: `APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.ts`
- Modify: `APP/src/app/features/members/pages/members-page/members-page.component.html`
- Modify: `APP/src/app/features/members/pages/members-page/members-page.component.ts`
- [ ] **Step 1: member-form-dialog — switch Gender + Status to central options**
In `member-form-dialog.component.ts`:
- Add import: `import { GENDER_OPTIONS, MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';`
- Replace the local `genderOptions = [{ text:'Male', value:'M' }, ...]` field with `readonly genderOptions = GENDER_OPTIONS;`
- Replace `readonly statusOptions = ['Member', 'Visitor', 'Inactive', 'Former'];` with `readonly statusOptions = MEMBER_STATUS_OPTIONS;`
- [ ] **Step 2: member-form-dialog — update HTML bindings**
In `member-form-dialog.component.html`:
- Gender dropdown currently `textField="text" valueField="value"`. Change `textField="text"` → `textField="label"`. Ensure `[valuePrimitive]="true"` is present (add it if missing). If there is a Gender `[defaultItem]="{ text: '-- Select --', value: null }"`, change `text` → `label` and make it bilingual: `[defaultItem]="{ label: '-- Select --/請選擇', value: null }"`.
- Status dropdown currently `<kendo-dropdownlist formControlName="status" [data]="statusOptions">` (bare strings). Change to:
```html
<kendo-dropdownlist formControlName="status" [data]="statusOptions"
textField="label" valueField="value" [valuePrimitive]="true"></kendo-dropdownlist>
```
- [ ] **Step 3: members-page — Status filter to central options + an "all" row**
In `members-page.component.ts`:
- Add import: `import { MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';`
- The current field is `readonly statusOptions = ['', 'Member', 'Visitor', 'Inactive', 'Former'];` where `''` means "all". Replace with:
```ts
readonly statusOptions = [
{ value: '', label: 'All Status/全部狀態' },
...MEMBER_STATUS_OPTIONS,
];
```
- [ ] **Step 4: members-page — update HTML binding**
In `members-page.component.html`, the Status filter is `<kendo-dropdownlist [data]="statusOptions" ...>` binding bare strings. Add the object bindings (keep the existing `[(ngModel)]`/filter target):
```html
<kendo-dropdownlist [data]="statusOptions" textField="label" valueField="value" [valuePrimitive]="true"
...keep existing ngModel/handlers...></kendo-dropdownlist>
```
The empty-string value still flows through unchanged as the "all" selection.
- [ ] **Step 5: Build to verify**
Run: `cd APP && npm run build`
Expected: build succeeds.
- [ ] **Step 6: Commit**
```bash
git add APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.html APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.ts APP/src/app/features/members/pages/members-page/members-page.component.html APP/src/app/features/members/pages/members-page/members-page.component.ts
git commit -m "feat(i18n): bilingual gender/status dropdowns on member form + members page"
```
---
## Task 8: User dialogs — Language & Roles
**Files:**
- Modify: `APP/src/app/features/users/components/create-user-dialog/create-user-dialog.component.{html,ts}`
- Modify: `APP/src/app/features/users/components/edit-user-dialog/edit-user-dialog.component.{html,ts}`
- Modify: `APP/src/app/features/members/components/create-user-dialog/create-user-dialog.component.{html,ts}`
> Three dialogs share the same shape (a `langOptions` array and a `roleOptions = [...ALL_ROLES]`). Apply the identical change to each — they are listed explicitly so you don't skip one.
- [ ] **Step 1: users/create-user-dialog — component**
In `users/components/create-user-dialog/create-user-dialog.component.ts`:
- Add import: `import { LANGUAGE_OPTIONS, ROLE_OPTIONS } from '../../../../shared/i18n/option-lists';`
- Replace the local `langOptions = [{ text:'English', value:'en' }, { text:'中文', value:'zh-TW' }]` with `readonly langOptions = LANGUAGE_OPTIONS;`
- Replace `roleOptions: string[] = [...ALL_ROLES];` with `readonly roleOptions = ROLE_OPTIONS;` (remove the now-unused `ALL_ROLES` import if nothing else uses it).
- [ ] **Step 2: users/create-user-dialog — HTML**
In the matching `.html`:
- Language dropdown is `[data]="langOptions" textField="text" valueField="value"`. Change `textField="text"` → `textField="label"`. Ensure `[valuePrimitive]="true"` is present.
- Roles multiselect is `<kendo-multiselect formControlName="roles" [data]="roleOptions" ...>` (bare strings). Add object bindings:
```html
<kendo-multiselect formControlName="roles" [data]="roleOptions"
textField="label" valueField="value" [valuePrimitive]="true" ...keep existing attrs...></kendo-multiselect>
```
`[valuePrimitive]="true"` ensures the form control still holds an array of role-code strings.
- [ ] **Step 3: users/edit-user-dialog — apply the same component + HTML changes**
Repeat Step 1 and Step 2 in `users/components/edit-user-dialog/create…`→ the `edit-user-dialog.component.ts` and `.html`. The `edit` dialog also pre-selects existing roles; because values are still role-code strings and `[valuePrimitive]="true"`, the existing `formControlName="roles"` value binds correctly with no further change.
- [ ] **Step 4: members/create-user-dialog — apply the same changes**
Repeat Step 1 and Step 2 in `members/components/create-user-dialog/create-user-dialog.component.ts` and `.html`. (Import path depth is the same four levels: `../../../../shared/i18n/option-lists`.)
- [ ] **Step 5: Build to verify**
Run: `cd APP && npm run build`
Expected: build succeeds. If the build flags an unused `ALL_ROLES` import, remove it in the offending file.
- [ ] **Step 6: Commit**
```bash
git add APP/src/app/features/users/components/create-user-dialog/create-user-dialog.component.html APP/src/app/features/users/components/create-user-dialog/create-user-dialog.component.ts APP/src/app/features/users/components/edit-user-dialog/edit-user-dialog.component.html APP/src/app/features/users/components/edit-user-dialog/edit-user-dialog.component.ts APP/src/app/features/members/components/create-user-dialog/create-user-dialog.component.html APP/src/app/features/members/components/create-user-dialog/create-user-dialog.component.ts
git commit -m "feat(i18n): bilingual language + roles selectors in user dialogs"
```
---
## Task 9: Document the convention
**Files:**
- Modify: `docs/PLANNING.md`
- [ ] **Step 1: Append the convention section**
Add a new section to `docs/PLANNING.md` (place it under the relevant frontend/UI area; if unsure, append at the end before any trailing footer):
```markdown
## 下拉雙語顯示約定 (Dropdown Bilingual Display)
所有業務下拉選單的選項同時顯示英文與中文,格式 `英文/中文`(無空格、英文在前;無中文則只顯示英文)。
- **DB 查表下拉**giving categories / ministries / expense category groups & subs):
在對應的 API service 載入時計算 `label = bilingual(name_en, name_zh)`,模板綁 `textField="label"`。
不改 DB、不改 schema、不改儲存值。
- **寫死 enum 下拉**payment method / expense status / member status / gender / language / roles):
選項一律定義在 `APP/src/app/shared/i18n/option-lists.ts`,型別 `{ value, label }`
模板綁 `textField="label" valueField="value" [valuePrimitive]="true"`。送出/儲存的 `value` 不變。
- 共用工具:`APP/src/app/shared/i18n/bilingual.ts` 的 `bilingual(en, zh)`。
- 新增下拉時沿用以上兩種模式,不要在 component 內各自寫死中文字串。
```
- [ ] **Step 2: Commit**
```bash
git add docs/PLANNING.md
git commit -m "docs: record dropdown bilingual display convention"
```
---
## Task 10: Full verification (build + browser preview)
**Files:** none (verification only).
- [ ] **Step 1: Clean build**
Run: `cd APP && npm run build`
Expected: succeeds with no errors.
- [ ] **Step 2: Run the unit suites**
Run: `cd APP && npm run test:ci`
Expected: `bilingual` and `option-lists` suites PASS (and no previously-passing suite regresses).
- [ ] **Step 3: Start the app and verify in the browser preview**
Start the dev server (`preview_start` / `npm start`) and, using the preview tools, confirm each of the following renders `英文/中文`:
- Offering-session page: **Type** shows e.g. `Worship/敬拜`; **Method** shows `Cash/現金`. Add one line → the **Type** column in the lines grid below also shows the bilingual label.
- Expense form dialog: **Ministry**, **大類 (group)**, **子項 (sub)** all bilingual.
- Expenses page: **Ministry** and **Status** filters bilingual.
- Member form dialog: **Gender**, **Status** bilingual. Members page: **Status** filter bilingual.
- User dialogs (create/edit): **Language** bilingual; **Roles** multiselect options bilingual.
Capture `preview_screenshot` evidence for the offering-session and expense dialog as proof.
- [ ] **Step 4: Confirm stored values are unchanged**
In the preview, select bilingual options and submit one offering line and (if feasible) one expense; via `preview_network` confirm the request payload still sends raw values (`paymentMethod: "Cash"`, `givingCategoryId: <int>`, status codes, role codes) — NOT the `英文/中文` label.
- [ ] **Step 5: Final confirmation**
No commit needed (verification only). Report results; if any dropdown still shows English-only or a payload carries a label string, return to the relevant task and fix.
---
## Notes for the implementer
- **Import-path depth:** `shared/i18n/...` is referenced from `features/<area>/pages/<page>/` (4 levels up → `../../../../shared/i18n/...`) and from `features/<area>/components/<comp>/` (also 4 levels up). Services under `features/<area>/services/` are 3 levels up (`../../../shared/i18n/...`). Verify the relative depth per file; the build will catch a wrong path.
- **Don't touch stored values.** Every change is display-only. `[valuePrimitive]="true"` + `valueField="value"` is what keeps form/DB values identical to today.
- **Out of scope (per spec §5):** server-sourced grid name columns (e.g. expense list ministry/category names), Angular i18n framework, DB/seed/migration changes, dashboard template demo dropdowns.