# petaV3 Property Match Admin Workbench Enhancement

> Status: implementation-ready
>
> Planned against: local `dev-chen` at `eaefc298` on 2026-07-24
>
> Target page: `/manage/property-match`
>
> Visual reference: petaV2 `/admin/property-match`
>
> Implementation owner: Claude, following the execution prompt in §14

## 1. Goal

Enhance the existing petaV3 Property Match administration page into a person-centric sales workbench similar to the petaV2 page:

- one row per safely identified person instead of one row per quiz attempt;
- headline counts and recommendation distribution;
- dense list and monthly calendar views;
- canonical petaV3 CRM context for membership/payment, booked property/unit and Zoom duration;
- appointment setter, closer and formal appointment time controls;
- tags and append-only remarks;
- expandable attempt history containing every answer and recommendation;
- server-side search, filters, sorting and pagination;
- the existing `view-property-match` / `manage-property-match` permissions.

This is an enhancement of the **Manage page only**. The public Property Match wizard, its decision engine, the shared `ContactVerification` component and its identity-resolution flow are not changed.

## 2. Non-negotiable decisions

### 2.1 Safe person identity

petaV2 groups submissions by normalized email, falling back to phone. petaV3 must not repeat that trust mistake.

Use these identities:

| Submission state | Admin person key | Behaviour |
| --- | --- | --- |
| `lead_id` is present | `lead:{lead_id}` internally | All submissions linked to that verified CRM lead form one person row. |
| `lead_id` is null | `submission:{id}` internally | The submission remains its own row, even if its claimed email/phone equals another row. |

Only a verified identity link may merge records. An unverified or unresolved visitor must never be grouped or mutated by claimed email/phone.

Do not expose numeric IDs as action identifiers. The page uses the latest representative submission UUID for routes and a public row key built from the lead UUID or submission UUID.

### 2.2 Person-scoped writes

Keep `PropertyMatchRepository::personQuery()` as the write authority:

- linked submissions fan status, assignment and tags out by `lead_id`;
- unlinked submissions update only the addressed row;
- remarks remain immutable rows and have no update/delete route.

Do not add email-based or phone-based update fan-out.

### 2.3 Canonical petaV3 CRM data only

The petaV2 page reads legacy tables. The enhanced petaV3 page must use:

| Display | petaV3 source |
| --- | --- |
| Member status/name | `Lead::subscriptions()` → active `MemberSubscription` → `Membership` |
| Paid amount | non-deleted `MemberSubscription.price_paid`, grouped by its stored `currency` |
| Property/unit | newest Active/Completed `Src\Engagement\Booking`, ordered by `booking_date DESC, id DESC`; Cancelled bookings are not displayed |
| Project name | `Booking::project()` → `Project::canonicalName()`; the project remains connected to the Catalogue |
| Unit | `Booking.unit_no`, with block/floor only when present |
| Zoom duration | `Lead::webinarAttendances()` summed by `duration_seconds` |
| Lead identity | `Lead::user.profile` and `Lead::user` |

Forbidden in this feature:

- `wf_stripe_charges`;
- `wf_property_bookings`;
- `zoom_event_participants`;
- any petaV2 database connection;
- any property source outside the existing petaV3 Project/Catalogue relationships.

If a row is unlinked, display `Unlinked capture` for CRM-only columns. Do not infer a CRM identity from its email or phone.

### 2.4 Workflow calendar stays module-local

`property_match_submissions.appointment_at` is the formal Property Match follow-up time and drives this module's calendar. Do not create or synchronize `appointments` records in this version: a canonical Appointment needs project/type/status semantics that this quiz does not currently provide, and automatic synchronization would risk duplicates.

Interpret `datetime-local` values in `config('app.user_timezone')`, convert them to `config('app.timezone')` for persistence, and format them back in the configured user timezone. Both currently default to `Asia/Kuala_Lumpur`. Do not use the browser's implicit timezone conversion.

### 2.5 V3 design, V2 information density

Reproduce the useful V2 workflow, not the old Blade implementation:

- use `ManageLayout`, Tailwind v4, the shared `DataTable`, `FilterDrawer`, `ActiveFilterChips`, Inertia and lucide icons;
- retain server-side pagination instead of the V2 `LIMIT 1000` load-all model;
- use V3 flash/error handling and server responses as the source of truth;
- do not copy V2 hard-coded staff lists or optimistic silent saves.

## 3. Verified starting state

The following already exists and must be reused:

| Area | Existing implementation |
| --- | --- |
| Manage route/page | `routes/web.php`, `app/Http/Controllers/Manage/PropertyMatch/SubmissionsController.php`, `resources/js/Pages/Manage/PropertyMatch/Index.vue` |
| Query filters | `app/Http/Requests/Manage/PropertyMatch/SubmissionQueryRequest.php` |
| Status request | `app/Http/Requests/Manage/PropertyMatch/UpdateStatusRequest.php` |
| Domain model | `src/PropertyMatch/PropertyMatchSubmission.php` |
| Writes | `src/PropertyMatch/Repositories/PropertyMatchRepository.php` |
| Remarks | `src/PropertyMatch/PropertyMatchRemark.php`, `property_match_remarks` |
| Workflow columns | `appointment_setter_id`, `closer_admin_id`, `appointment_at`, `tags` already exist |
| Permissions | `view-property-match`, `manage-property-match` already exist |
| Lead history | `resources/js/Pages/Manage/Leads/Partials/Tabs/PropertyMatchTab.vue` already exists |
| Staff scope | `app/Http/Controllers/Concerns/ResolvesAssignableManagers.php` |
| Comparable admin module | Rental Estimate controller, requests, tests and Manage page |
| Calendar pattern | `resources/js/Pages/Manage/Calendar/Index.vue` |

Current gaps:

- the index paginates individual attempts rather than people;
- only status has a public Manage mutation route;
- the repository supports assignment, tags and remarks, but the UI/controller do not expose them;
- no Property Match Manage feature-test suite exists;
- the current search method type-hints the raw Diver value as `string`, so a crafted array query can produce a `TypeError`;
- the page does not include CRM context, stats, calendar, merged attempt history or operations.

No database migration is planned. The required workflow and remark fields already exist.

## 4. Target behaviour

### 4.1 Header and global statistics

Show six cards:

1. `TOTAL`: distinct safe people and total attempts;
2. `NEW`: people whose highest workflow status is New;
3. `BOOKED+`: people whose highest status is Booked, Contacted or Closed;
4. `CONVERSION`: `BOOKED+ / TOTAL`, zero-safe and rounded to a whole percent;
5. `SCHEDULED`: people with `appointment_at`;
6. `UNASSIGNED CLOSER`: people with no `closer_admin_id`.

These cards describe the global non-deleted Property Match dataset and do not change with list filters, matching the V2 overview behaviour. The paginator total communicates the current filtered result count.

Below the cards, show recommendation distribution chips:

- group by non-null `match_key`;
- label with the newest non-empty frozen result title for that key, otherwise `match_key`;
- count **attempts**, not people;
- order by count descending, then title;
- clicking a chip applies the recommendation filter.

### 4.2 Toolbar

Provide:

- `List` and `Calendar` tabs;
- debounced search;
- recommendation filter;
- status filter;
- verification filter;
- appointment setter filter;
- closer filter;
- `Unassigned closer`;
- `Scheduled` / `Unscheduled`;
- submission date range for List;
- calendar month navigation for Calendar.

Search matches any attempt belonging to the person:

- name, email or normalized phone digits;
- recommendation title/key, unit label and result tag;
- tags;
- remark body or snapshotted remark author.

Literal `%` and `_` in search text must be escaped before `LIKE` clauses so user input cannot become an unintended wildcard.

Malformed shapes such as `?verified[]=x`, `?search[]=x` and nested arrays are ignored safely rather than causing a 500.

### 4.3 Dense person table

Use a horizontally scrollable dense DataTable with a sticky first person column. Columns:

1. Person;
2. Contact;
3. Membership/payment;
4. Booked property/unit;
5. Zoom duration;
6. Latest recommendation/unit;
7. Appointment setter;
8. Closer;
9. Appointment time;
10. Tags/latest remark;
11. Highest status;
12. Latest submission;
13. Actions/expand.

Row semantics:

- linked person contact comes from the canonical Lead user/profile, falling back to the latest captured snapshot only when a linked/deleted CRM relation no longer provides a value;
- unlinked contact comes from the latest submission and is visibly labelled unlinked;
- repeat count appears as `×N`;
- status is the highest `PropertyMatchSubmission::STATUS_RANK` across attempts;
- recommendation is the latest attempt's frozen result;
- Zoom duration is formatted as hours/minutes;
- membership names and paid totals retain their actual currencies;
- no currency is guessed;
- latest booking is the newest Active/Completed booking by booking date/id; Cancelled bookings are omitted;
- a linked lead has an `Open lead` action;
- a phone with digits has a WhatsApp action;
- a viewer without `manage-property-match` sees values but no mutation controls.

### 4.4 Expanded person detail

Expansion lazily calls a view-permitted JSON endpoint. Do not ship every attempt and remark inside the index paginator.

The expanded panel combines the detail endpoint and the separate remarks endpoint to render:

- canonical person/CRM summary;
- all attempts newest-first;
- for each attempt: submitted time, verification state, language, preferred slot, frozen recommendation, result details and every stored question/answer;
- merged remarks newest-first, cursor-paginated from `/remarks`;
- tag editor;
- append-only remark composer;
- Lead and WhatsApp links.

The index initially returns only `remarks_count` and an optional latest-remark excerpt. A second remarks page is fetched only when requested.

### 4.5 Calendar

Calendar rules:

- Monday-first, fixed 42-cell month grid;
- one event per safely grouped person, never one duplicated event per linked attempt;
- events cover the full visible grid, from the first displayed Monday through the last displayed Sunday, including leading/trailing days from adjacent months;
- event chip shows time, person and closer;
- chip color is deterministic by closer UUID; unassigned uses slate;
- previous month, Today and next month controls;
- clicking an event navigates with `view=list`, `focus={uuid}`, `page=1`, removes `month`, date range and conflicting person filters, promotes the person to the first page, expands it and scrolls it into view;
- `focus={representative_submission_uuid}` is a read-only navigation hint, not an authorization mechanism.

The current shared DataTable owns expansion state internally. Add a backward-compatible optional controlled-expansion prop to it for this focus flow; the default must preserve every existing caller's behaviour.

## 5. Read architecture

Create `src/PropertyMatch/Queries/PropertyMatchAdminQuery.php`. It owns read-only grouping, filtering, pagination, detail, stats, distribution and calendar projections. Keep the controller responsible for HTTP, permission middleware, request validation and response shapes.

### 5.1 Two-stage safe grouping query

Do not materialize every matching ID in PHP.

1. Build the existing Eloquent attempt-match query from `SubmissionQueryRequest` for **existential attempt filters only**: search, recommendation and submission date.
2. Create a linked-person branch:
   - only rows with `lead_id`;
   - include lead IDs found by the match query;
   - group all non-deleted rows by `lead_id`.
3. Create an unlinked branch:
   - only rows without `lead_id`;
   - include only row IDs found by the match query;
   - each row is its own group.
4. `UNION ALL` the two branches into a derived person-summary query.
5. Apply **person-summary filters** after grouping: highest status, current setter, current closer and scheduled/unscheduled.
6. Define verification at person level: `verified` means at least one verified attempt; `unverified` means zero verified attempts.
7. Apply person-level sort, optional focus promotion and server pagination.
8. Hydrate only the current page's representative submissions and linked leads in bounded bulk queries.

This split ensures:

- a filter can match an older attempt while the row still displays the latest attempt;
- `attempts_count` remains the person's true total, not the filtered subset;
- linked and unlinked grouping rules cannot collide;
- the list never loads the whole table.

Do not apply status/setter/closer/schedule filters to the attempt-match query. For example, an old New attempt must not make a person whose highest current status is Booked appear in the New filter.

Refactor `SubmissionQueryRequest` deliberately:

- its Diver `$filterable` list/query methods cover only `search`, `match`, `date_from` and `date_to`;
- a `personFilters()` method returns safely normalized `status`, `verified`, `appointment_setter`, `closer` and `schedule` values for the grouped query;
- a `sanitizedFilters()` method returns only scalar/flat-array values safe to echo to Inertia;
- array/nested values for single-value filters are discarded;
- neither method accepts numeric staff IDs from the URL; staff filters use UUIDs and are resolved to allowed IDs server-side.

Summary projection:

- internal group type/id;
- latest submission ID and date;
- attempt count;
- highest status;
- verified-attempt count;
- appointment setter, closer, appointment time and tags from the latest representative row;
- public representative UUID after hydration.

The numeric status constants are ordered in the same direction as `STATUS_RANK`, but the query/service must map through the rank contract rather than silently assuming that forever. A test locks this invariant.

### 5.2 Bounded CRM hydration

For the linked lead IDs on the current page only:

- load lead user/profile;
- load active subscriptions with membership;
- load all non-deleted subscriptions needed to total `price_paid` by stored currency;
- query bookings by those lead IDs with project/Catalogue relations, then select the latest preferred booking in memory;
- sum webinar attendance duration by lead ID;
- load appointment setter/closer profiles in bulk.

Do not query CRM relations per table row. Add a feature test that compares SQL query count for a one-person page and a ten-person page; the ten-person page may use at most two more SQL statements than the one-person page.

### 5.3 Read contracts

`GET /manage/property-match` Inertia props:

```text
people                Laravel paginator; one row per safe person
filters               sanitized scalar/flat-array values only
view                  list | calendar
month                 YYYY-MM
userTimezone          shared configured display/input timezone
statuses              PropertyMatchSubmission::STATUSES
matchOptions          [{ value, label, count }]
admins                group-scoped [{ uuid, name }]
stats                 global headline values
statusCounts          person counts by highest status
setterCounts          person counts by setter UUID + unassigned
closerCounts          person counts by closer UUID + unassigned
calendarEvents        present for calendar view, otherwise []
sort / direction
focus                 representative UUID or null
```

Each person row:

```text
row_key
representative_uuid
linked
lead_uuid
attempts_count
contact { name, email, phone }
latest_attempt { match_key, match_title, result, slot, is_verified, created_at }
highest_status { value, label, color }
appointment_setter { uuid, name } | null
closer { uuid, name } | null
assignment_mutable
appointment_at {
  iso
  local_input     // Y-m-dTH:i in config('app.user_timezone'); feed directly to datetime-local
  display
  date
  time
} | null
tags[]
remarks_count
latest_remark { author_name, body, created_at } | null
crm {
  memberships[]
  payments [{ currency, amount }]
  booking { project_name, unit_no, status_label } | null
  zoom_duration_seconds
}
```

JSON endpoints:

```text
GET /manage/property-match/{uuid}/detail
GET /manage/property-match/{uuid}/remarks?cursor=...
```

Both resolve the addressed submission, then use `lead_id` only when present. Detail never groups by email/phone.

- `/detail` returns person/CRM summary and attempts, but no remark collection.
- `/remarks` returns first/subsequent cursor pages ordered by `id DESC`, scoped to all linked-person submission IDs or only the addressed unlinked ID.
- `PersonDetail` fetches both resources on first expansion and refreshes only the affected resource after a write.

## 6. Write architecture

Add routes inside the existing Property Match permission group:

```text
PUT  /manage/property-match/{uuid}/status
PUT  /manage/property-match/{uuid}/assign
POST /manage/property-match/{uuid}/remarks
PUT  /manage/property-match/{uuid}/tags
```

Keep the two GET detail/remarks endpoints under `view-property-match`. Put every mutation under `manage-property-match`.

Create:

- `app/Http/Requests/Manage/PropertyMatch/AssignRequest.php`
- `app/Http/Requests/Manage/PropertyMatch/StoreRemarkRequest.php`
- `app/Http/Requests/Manage/PropertyMatch/UpdateTagsRequest.php`

Contracts:

```text
AssignRequest
  appointment_setter: sometimes, nullable, staff UUID
  closer:             sometimes, nullable, staff UUID
  appointment_at:     sometimes, nullable, date_format:Y-m-d\TH:i

StoreRemarkRequest
  body: required, string, max:2000

UpdateTagsRequest
  tags: required, array, max:12
  tags.*: string, max:24
```

The assignment controller:

- starts a transaction;
- locks the addressed submission and, when linked, every submission with the same `lead_id` in stable ID order before resolving and writing;
- resolves setter/closer UUIDs through `assignableManagerQuery($actor)`;
- stores `users.id`, never UUIDs;
- revalidates that selected users are active manage users in the actor's allowed group;
- prevents a group-scoped actor from taking over a row whose existing setter or closer belongs to another group;
- locks existing setter/closer `User` + `Admin` rows to check source group ownership, and locks target `User` + `Admin` + role-pivot state to require an active manage user, following the proven Rental Estimate guard pattern; an inactive historical same-group source remains correctable;
- sends only keys present in the request to `PropertyMatchRepository::assign()`;
- allows an empty value to clear a field;
- returns 422 for invalid/out-of-scope targets;
- never accepts raw numeric user IDs.

The read row exposes `assignment_mutable`. For an out-of-scope existing owner the frontend hides/disables assignment controls, but the locked backend guard remains authoritative. The frontend sends only the changed assignment field. It must not submit stale setter, closer and appointment values together after every single control change.

Status continues to use `PropertyMatchRepository::changeStatus()`. All four existing statuses remain admin-selectable, matching V2; this task does not introduce a new transition matrix.

Tags use repository cleanup as the final invariant. Request limits and repository limits must both be exactly 12 tags × 24 characters so the server does not silently truncate an otherwise valid request.

Remarks are appended to the representative submission and displayed across all submissions in that linked person group. There is deliberately no edit/delete route.

## 7. Ordered implementation phases

Every phase follows RED → GREEN → focused review → commit. Do not combine all work into one final commit.

### Phase 0 — Branch and baseline

**Owned files:** none.

1. In `/Users/dadadineiyou/Documents/GitHub/petav3-dev-chen-integration`, require a clean `dev-chen`.
2. Record `git rev-parse HEAD` and current failures.
3. Create `feat/property-match-admin-workbench` from local `dev-chen`.
4. Confirm `phpunit.xml` uses the test database.
5. Use Herd PHP 8.4 explicitly; the Homebrew default is PHP 7.4 and cannot parse the repository's null-safe syntax.

```bash
cd /Users/dadadineiyou/Documents/GitHub/petav3-dev-chen-integration
git status --short
git switch -c feat/property-match-admin-workbench dev-chen
grep DB_DATABASE phpunit.xml
PETAV3_PHP="$HOME/Library/Application Support/Herd/bin/php"
set -o pipefail
"$PETAV3_PHP" vendor/bin/phpunit tests/Feature/PropertyMatch tests/Unit/PropertyMatch 2>&1 | tee /tmp/petav3-pm-before.txt
"$PETAV3_PHP" vendor/bin/phpunit 2>&1 | tee /tmp/petav3-full-before.txt
npm run test 2>&1 | tee /tmp/petav3-vitest-before.txt
npm run build 2>&1 | tee /tmp/petav3-build-before.txt
```

**Exit:** baseline results are recorded. Any pre-existing failure is named and later compared exactly; do not “fix” unrelated baseline failures.

### Phase 1 — Lock the person read model with tests

**Create:**

- `tests/Feature/PropertyMatch/ManagePropertyMatchTest.php`
- `src/PropertyMatch/Queries/PropertyMatchAdminQuery.php`

**Modify:**

- `app/Http/Requests/Manage/PropertyMatch/SubmissionQueryRequest.php`
- `app/Http/Controllers/Manage/PropertyMatch/SubmissionsController.php`

**RED tests:**

1. view permission required;
2. two linked attempts become one row with `attempts_count=2`;
3. two unlinked rows with the same email/phone remain two rows;
4. representative is the latest attempt, while status is highest progress;
5. searching an older attempt returns the person and preserves the full attempt count;
6. recommendation/status/verified filters return person groups;
7. an old New attempt does not make a highest-status Booked person match the New filter;
8. `%` and `_` are literal search characters;
9. array/nested query shapes do not 500;
10. pagination total counts people, not attempts;
11. focus promotes the addressed person's row without authorizing anything;
12. global stats count safe people while distribution counts attempts;
13. distribution uses the newest non-empty frozen title for a key;
14. one-person versus ten-person list hydration adds at most two SQL statements.

Implement the two-stage query from §5, sanitize echoed filters, and keep sorting limited to:

- latest submission;
- highest status;
- attempts count;
- appointment time.

Do not claim unsupported CRM-column sorting.

```bash
"$PETAV3_PHP" vendor/bin/phpunit tests/Feature/PropertyMatch/ManagePropertyMatchTest.php
"$PETAV3_PHP" vendor/bin/phpunit tests/Feature/PropertyMatch
```

**Commit:** `feat(property-match): add person-centric admin read model`

### Phase 2 — Expose safe workflow mutations

**Create:**

- `app/Http/Requests/Manage/PropertyMatch/AssignRequest.php`
- `app/Http/Requests/Manage/PropertyMatch/StoreRemarkRequest.php`
- `app/Http/Requests/Manage/PropertyMatch/UpdateTagsRequest.php`

**Modify:**

- `routes/web.php`
- `app/Http/Controllers/Manage/PropertyMatch/SubmissionsController.php`
- `src/PropertyMatch/Repositories/PropertyMatchRepository.php` only where a test proves a missing invariant
- `tests/Feature/PropertyMatch/ManagePropertyMatchTest.php`
- `tests/Feature/PropertyMatch/PropertyMatchRepositoryTest.php`

**RED tests:**

1. view-only user receives 403 for every mutation route;
2. linked status/assignment/tags fan out to all attempts;
3. unlinked mutation changes only the addressed row;
4. assignment accepts scoped staff UUIDs and stores IDs;
5. out-of-group, inactive and non-manage users are rejected;
6. partial assignment updates do not overwrite untouched fields;
7. clearing setter/closer/time works;
8. with different test values for `app.timezone` and `app.user_timezone`, appointment time converts for storage and round-trips exactly for `datetime-local`;
9. tags reject over 12/24 instead of silently accepting and truncating;
10. remark creation is append-only and preserves the author snapshot on the representative submission;
11. no PUT/PATCH/DELETE remark route exists;
12. two assignment requests addressing different UUIDs for the same linked lead serialize on the complete person row set and recheck source/target scope;
13. `assignment_mutable` is false for an existing setter/closer outside the actor's group, and a direct mutation is still rejected;
14. an inactive or role-revoked historical setter/closer in the actor's own group remains correctable.

Use the repository for every write. Do not put Eloquent update statements for Property Match workflow fields directly in Vue-facing controller methods.

```bash
"$PETAV3_PHP" vendor/bin/phpunit tests/Feature/PropertyMatch/ManagePropertyMatchTest.php
"$PETAV3_PHP" vendor/bin/phpunit tests/Feature/PropertyMatch/PropertyMatchRepositoryTest.php
```

**Commit:** `feat(property-match): expose admin workflow operations`

### Phase 3 — Add canonical CRM enrichment and lazy detail

**Modify:**

- `src/PropertyMatch/Queries/PropertyMatchAdminQuery.php`
- `app/Http/Controllers/Manage/PropertyMatch/SubmissionsController.php`
- `routes/web.php`
- `tests/Feature/PropertyMatch/ManagePropertyMatchTest.php`

**RED tests:**

1. linked rows use canonical Lead contact while attempts retain captured snapshots;
2. active membership names display from `member_subscriptions`;
3. paid totals are grouped by stored currency and never labelled with a guessed currency;
4. newest Active/Completed booking exposes `Project::canonicalName()` and unit while Cancelled bookings are omitted;
5. Zoom duration is the attendance sum;
6. unlinked rows expose no CRM membership/payment/property/Zoom identity;
7. detail returns every linked attempt newest-first;
8. detail for unlinked UUID returns only that submission;
9. remarks cursor never crosses to a different lead or unlinked row;
10. deleted subscriptions/bookings/submissions do not leak into projections;
11. view-only users can read detail and remarks, while a user without view permission cannot.

Run a literal source scan to ensure no legacy table name was introduced:

```bash
rg -n "wf_stripe_charges|wf_property_bookings|zoom_event_participants" \
  src/PropertyMatch app/Http/Controllers/Manage/PropertyMatch \
  app/Http/Requests/Manage/PropertyMatch resources/js/Pages/Manage/PropertyMatch
```

Expected: zero matches.

```bash
"$PETAV3_PHP" vendor/bin/phpunit tests/Feature/PropertyMatch
```

**Commit:** `feat(property-match): enrich admin rows from canonical CRM`

### Phase 4 — Build the V2-style list workbench in V3

**Create:**

- `resources/js/Pages/Manage/PropertyMatch/Partials/PersonTable.vue`
- `resources/js/Pages/Manage/PropertyMatch/Partials/PersonDetail.vue`
- `resources/js/Pages/Manage/PropertyMatch/admin.js`
- `resources/js/Pages/Manage/PropertyMatch/admin.test.js`

**Modify:**

- `resources/js/Pages/Manage/PropertyMatch/Index.vue`
- `resources/js/composables/useResourceIndex.js`

**Frontend tests:**

1. money formatter keeps the provided currency and leaves absent currency unlabeled;
2. duration formatter handles zero, minutes and multi-hour values;
3. WhatsApp URL strips non-digits and omits invalid phones;
4. tag parsing trims, deduplicates and reports the 12/24 client limits;
5. detail cache loads once, can refresh after a write and surfaces fetch errors;
6. changed assignment controls generate partial payloads;
7. view-only mode renders no interactive mutation controls;
8. filter serialization preserves flat multi-selects and rejects nested values;
9. search icon/input padding do not overlap at mobile and desktop classes.
10. Property Match can opt into preserving `view` and `month` across search/filter visits without changing existing composable callers.

Implementation:

- keep `Index.vue` as page orchestration/header/filters/tabs;
- put dense DataTable slots and inline workflow controls in `PersonTable.vue`;
- put attempts, CRM detail, tags and remarks in `PersonDetail.vue`;
- reuse `useResourceIndex()` for URL-backed filters;
- add an optional `preserveParams` argument to `useResourceIndex()` (defaulting to its current behaviour) and pass `['view', 'month']` from Property Match; `focus` is intentionally cleared by a new filter;
- use server-provided admins/statuses instead of hard-coded lists;
- use per-row busy/error state;
- on failed Inertia mutation, retain the server value and display the validation/flash error;
- refresh the row/detail after successful writes;
- do not create a second client-side status/identity business model.

```bash
npm run test -- resources/js/Pages/Manage/PropertyMatch/admin.test.js
npm run test
npm run build
```

**Commit:** `feat(property-match): build person sales workbench`

### Phase 5 — Add the monthly calendar

**Create:**

- `resources/js/Pages/Manage/PropertyMatch/Partials/CalendarTab.vue`

**Modify:**

- `resources/js/Components/DataTable.vue`
- `resources/js/Pages/Manage/PropertyMatch/Index.vue`
- `resources/js/Pages/Manage/PropertyMatch/admin.js`
- `resources/js/Pages/Manage/PropertyMatch/admin.test.js`
- `src/PropertyMatch/Queries/PropertyMatchAdminQuery.php`
- `app/Http/Controllers/Manage/PropertyMatch/SubmissionsController.php`
- `tests/Feature/PropertyMatch/ManagePropertyMatchTest.php`

**RED tests:**

Backend:

1. a scalar exact `YYYY-MM` selects the month; malformed, array or nested input falls back to the current month in `config('app.user_timezone')` without 422/500;
2. events cover the 42-cell visible Monday–Sunday window, including its adjacent-month boundary days and excluding dates outside that window;
3. repeated linked attempts produce one event;
4. unlinked events remain distinct;
5. staff/search filters apply to calendar events;
6. event payload exposes UUIDs/names but no numeric user/lead IDs.

Frontend:

1. month grid always has 42 Monday-first cells;
2. leap year and year boundary navigation;
3. Today resets month correctly;
4. event colors are deterministic per closer;
5. event click creates a List URL with `focus` and without conflicting filters;
6. focused row expands and scrolls after the Inertia visit;
7. the DataTable controlled-expansion prop opens the focused `row_key`, reacts to a changed key and leaves legacy uncontrolled callers unchanged;
8. display/input formatting uses the shared server `userTimezone`, not the browser's implicit timezone.

Add a minimal optional `expandedKeys` prop to DataTable and reconcile it into the component's existing internal map. `PersonTable` passes `row-key="row_key"` and the focused row key. Follow the current Manage Calendar styling and the padded-window calculation in `app/Http/Controllers/Manage/Calendar/CalendarController.php`, but keep the new component/query scoped to Property Match. Do not refactor the general Calendar module.

```bash
"$PETAV3_PHP" vendor/bin/phpunit tests/Feature/PropertyMatch/ManagePropertyMatchTest.php
npm run test -- resources/js/Pages/Manage/PropertyMatch/admin.test.js
npm run build
```

**Commit:** `feat(property-match): add appointment calendar view`

### Phase 6 — Regression, handbook and browser acceptance

**Modify:**

- `docs/modules_handbook/main/property-match/readMe.md`

The handbook must document:

- safe grouping rules;
- stats/distribution units;
- filters and list/calendar views;
- canonical CRM sources;
- every read/write route;
- permission split;
- append-only remarks and tag limits;
- module-local appointment semantics;
- the explicit non-goals in §10.

Run:

```bash
PETAV3_PHP="$HOME/Library/Application Support/Herd/bin/php"
set -o pipefail
"$PETAV3_PHP" vendor/bin/pint --test
"$PETAV3_PHP" vendor/bin/phpunit tests/Feature/PropertyMatch tests/Unit/PropertyMatch
npm run test
npm run build
"$PETAV3_PHP" vendor/bin/phpunit 2>&1 | tee /tmp/petav3-pm-after-full.txt
```

Compare full-PHPUnit failures with `/tmp/petav3-full-before.txt`. There must be zero new failures. Compare the focused Property Match result with `/tmp/petav3-pm-before.txt`.

Perform the browser checklist in §8 and attach screenshots to the handoff; screenshots are verification artifacts, not repository source files.

**Commit:** `docs(property-match): document admin workbench`

### Phase 7 — Final independent review and local integration

Review the entire range against `dev-chen`:

```bash
git log --oneline dev-chen..feat/property-match-admin-workbench
git diff --check dev-chen...feat/property-match-admin-workbench
git diff --stat dev-chen...feat/property-match-admin-workbench
```

Required review lenses:

1. identity isolation and cross-person data leakage;
2. permissions and scoped staff assignment;
3. transaction/lock correctness;
4. query growth, pagination and CRM N+1;
5. timezone/date handling;
6. Vue busy/error/accessibility states;
7. test truthfulness and handbook accuracy.

Fix review findings in separate atomic commits and rerun affected suites.

After all checks pass, integrate only this feature's commits into local `dev-chen` according to `AGENTS.md`. Prefer a fast-forward when the branch is directly based on unchanged local `dev-chen`; otherwise cherry-pick the exact `dev-chen..feat/property-match-admin-workbench` commit list into the clean dev-chen worktree. Create a recoverable backup before synchronizing a stale target. Do not push.

## 8. Browser acceptance with local demo data

Create local-only demo data with names prefixed `[Demo PM Admin]`. Do not add a production demo seeder and do not use real customer data in screenshots.

The fixture set must include:

1. one linked lead with three attempts, active membership, payment, booking, Zoom attendance, setter, closer, appointment, tags and two remarks;
2. one linked lead with two attempts and no appointment;
3. one linked lead with Booked status and an unassigned closer;
4. two unlinked captures sharing the same email/phone, proving they stay separate;
5. one scheduled event on the previous/next month boundary;
6. one row without CRM enrichment;
7. at least three different recommendations.

At desktop width capture:

1. unfiltered List header, six stats cards and recommendation chips;
2. dense table showing the CRM/workflow columns;
3. expanded linked person with three attempts;
4. tags and append-only remarks;
5. setter/closer/appointment edits after save;
6. search matching text from an older attempt;
7. unassigned closer filter;
8. Calendar month with several closer colors;
9. calendar event → focused/expanded List row;
10. view-only account with all mutations hidden.

At mobile width verify:

1. toolbar stacks without overlap;
2. search icon never covers placeholder/text;
3. stats and chips wrap;
4. table scrolls horizontally without clipping the sticky person column;
5. expanded detail remains readable.

Also verify:

- linked repeat attempts display one row;
- the two identical-contact unlinked captures display two rows;
- `TOTAL` people and total attempts are correct;
- recommendation distribution totals attempts;
- membership/payment currencies are correct;
- no browser console errors;
- no failed XHR/Inertia requests.

After screenshots, remove only the `[Demo PM Admin]` records created for acceptance. Never truncate shared local tables.

## 9. Commit sequence

Expected implementation commits:

1. `feat(property-match): add person-centric admin read model`
2. `feat(property-match): expose admin workflow operations`
3. `feat(property-match): enrich admin rows from canonical CRM`
4. `feat(property-match): build person sales workbench`
5. `feat(property-match): add appointment calendar view`
6. `docs(property-match): document admin workbench`
7. review-fix commits only when an independent review finds a real issue

Tests belong in the same green commit as the behaviour they prove. Do not commit a deliberately red test phase to `dev-chen`.

## 10. Explicit non-goals

- No change to `/property-match` or `/{country}/property-match`.
- No change to `ContactVerification.vue`, OTP endpoints or identity resolution.
- No change to the Property Match recommendation engine or public copy.
- No petaV2 production-data import.
- No automatic email/phone deduplication.
- No new status model or Kanban.
- No automatic lead owner/distribution assignment.
- No synchronization into the canonical `appointments` table.
- No CSV/Excel export.
- No refactor of the general Manage Calendar.
- No changes to Rental Estimate.
- No deployment and no push.

If the mentor wants historical petaV2 admin rows imported, stop and write a separate migration/import plan. It needs explicit field mapping, identity conflict policy, idempotency keys and dry-run behaviour; it must not be smuggled into this UI enhancement.

## 11. Risks and controls

| Risk | Control |
| --- | --- |
| Unverified contact merges two people | Only `lead_id` groups; every unlinked row is isolated. |
| Filter matches only one attempt and loses history | Match subquery chooses person keys; summary re-reads all attempts. |
| N+1 from CRM columns | Bulk hydrate only current page; query-growth test. |
| Duplicate calendar entries | Calendar groups by safe person key. |
| Cross-group staff assignment | Resolve UUID through scoped active-manage query inside locked transaction. |
| Stale inline control overwrites another field | Each control sends only its changed key. |
| Currency mislabelling | Preserve `MemberSubscription.currency`; never default to MYR. |
| Time moves because of implicit browser conversion | Parse in configured `app.user_timezone`, store in `app.timezone`, return an explicit local-input string and test exact round-trip. |
| Huge index payload | Lazy detail/remarks; server-side person pagination. |
| Wildcard search becomes overly broad | Escape `%`, `_` and the escape character in LIKE values. |
| V2 legacy data becomes a hidden dependency | Source scan and canonical-model tests. |
| Viewer can mutate through hidden endpoints | Permission middleware and feature tests, not UI-only guards. |

## 12. Definition of done

The task is complete only when:

- `/manage/property-match` shows a V3-styled V2-like person workbench;
- linked attempts group by `lead_id`, and unlinked identical contacts remain separate;
- headline statistics and recommendation distribution use their documented units;
- list filters/search/sort/pagination operate on people without losing attempt counts;
- membership/payment/property/unit/Zoom come only from canonical petaV3 models;
- status, setter, closer, appointment, tags and append-only remarks work with permissions;
- detail is lazy, complete and identity-isolated;
- monthly calendar deduplicates linked people and focuses the selected List row;
- malformed query shapes do not 500;
- no legacy table names are introduced;
- targeted PHPUnit, Vitest and build pass;
- full PHPUnit has no failures beyond the recorded Phase 0 baseline;
- desktop/mobile browser acceptance and screenshots pass;
- handbook facts match code;
- independent review has no unresolved Important/Critical finding;
- only task commits are integrated into local `dev-chen`;
- nothing is pushed.

## 13. Plan mutation protocol

If implementation discovers a contradiction:

1. stop the affected phase;
2. record the exact repository evidence;
3. update this plan before coding around it;
4. state which invariant or file list changed and why;
5. rerun the tests from every affected earlier phase.

Do not silently replace:

- the `lead_id` grouping rule;
- canonical petaV3 CRM sources;
- permission boundaries;
- module-local appointment semantics;
- the non-goals.

Those changes require explicit user/mentor approval.

## 14. Claude execution prompt

Copy the prompt below into Claude from the petaV3 repository:

```text
Implement the Property Match admin enhancement exactly from:
docs/plans/2026-07-24-property-match-admin-enhancement.md

Before changing code:
1. Read the repository AGENTS.md and the entire plan.
2. Work in /Users/dadadineiyou/Documents/GitHub/petav3-dev-chen-integration.
3. Require a clean local dev-chen, then create feat/property-match-admin-workbench.
4. Use "$HOME/Library/Application Support/Herd/bin/php" (PHP 8.4), not the
   Homebrew PHP 7.4 binary.
5. Record the Phase 0 PHPUnit/Vitest/build baseline.

Execute Phases 1–7 in order and test first within every phase. Commit each green
phase atomically using the commit sequence in §9. Do not push.

Load-bearing rules:
- This is a Manage-page enhancement only. Do not alter the public Property Match
  wizard, ContactVerification, OTP or identity-resolution flow.
- Group only submissions with the same non-null lead_id. Every lead_id=null row
  remains isolated even when email/phone matches.
- All actions use representative submission UUIDs; never trust email/phone for
  grouping or mutation.
- Reuse PropertyMatchRepository for writes and preserve linked-person fan-out /
  unlinked-row isolation.
- Use only canonical petaV3 CRM models: MemberSubscription/Membership,
  Engagement Booking + Project::canonicalName()/Catalogue relationships, and
  webinarAttendances. Never query petaV2 legacy tables.
- Keep appointment_at module-local; do not write canonical Appointment rows.
- Resolve setter/closer UUIDs through the actor-scoped active manage-user pool
  inside the locked write decision.
- Keep all reads server-paginated and detail/remarks lazy; prove there is no
  per-person CRM N+1.
- Keep request limits and repository limits aligned at 12 tags x 24 characters.
- Preserve stored currencies and the configured app/user timezone conversion for appointment time.
- Do not add unrequested export, import, Kanban, auto-assignment or refactors.

At the end:
1. Run Pint, Property Match PHPUnit, all Vitest, build and full PHPUnit.
2. Compare full PHPUnit with the recorded baseline and report exact counts.
3. Create local [Demo PM Admin] fixtures, complete every browser check in §8,
   save screenshots for the handoff, then remove only those demo rows.
4. Run independent reviews for security/identity, backend/query correctness,
   frontend/accessibility and test/doc truthfulness. Fix all Important/Critical
   findings and rerun affected checks.
5. Show git log dev-chen..feat/property-match-admin-workbench and confirm only
   task commits exist.
6. Integrate only the task commits into clean local dev-chen per AGENTS.md.
7. Do not push or deploy.

If repository evidence contradicts the plan, follow §13: stop, document the
evidence and amend the plan explicitly. Do not silently change a load-bearing
rule. Final report must list commits, files, test results, browser screenshots,
review findings/fixes and local integration status.
```
