# Draft Row-Level Tracking — Frontend Integration Guide

This document describes all backend contracts the frontend code generator needs to integrate with the extended Draft system. The new system adds per-row dirty tracking and a draft workflow on top of the existing header-level draft.

---

## Overview

| Concern | Before | After |
|---------|--------|-------|
| Storage | Full JSON blob per save | Header blob + individual dirty rows |
| Save trigger | Every timer tick | Header every 30s; rows only on change |
| Grid rows on final save | DELETE all + re-INSERT all | Only changed rows (New / Modified / Deleted) |
| Draft lifecycle | None | Active → Suspended / Transferred → Submitted / Discarded / Expired |
| Draft workflow | None | Pool, Share, Discard, Crash-recovery |

---

## Enums

### RowState (`GB5Shared.Enums.RowState`)

| Value | Int | When to use |
|-------|-----|-------------|
| `Unchanged` | 0 | Row is clean — **never send to the API** |
| `New` | 1 | Row was added in this session |
| `Modified` | 2 | Row existed on the server and was edited |
| `Deleted` | 3 | Row existed on the server and was removed |

**Rule:** The FE must maintain a per-row `RowState` field in the grid model. Only rows with `RowState ≠ 0` should be upserted.

### DraftStatus (`GB5Shared.Enums.DraftStatus`)

| Value | Int | Meaning |
|-------|-----|---------|
| `Active` | 1 | Currently being edited by owner |
| `Suspended` | 2 | Paused by owner |
| `Transferred` | 3 | Handed off to another user |
| `OnHold` | 4 | On hold (workflow) |
| `Submitted` | 5 | Final save completed — draft promoted |
| `Discarded` | 6 | Manually discarded |
| `Expired` | 7 | Orphan cleanup expired it |

---

## Base Fields Required on Every Entity Line DTO

Every grid-row object sent to or received from the backend must include these infrastructure fields (in addition to entity-specific business fields):

```typescript
interface GridRowBase {
  rowGuid: string;        // UUID — client-generated on row creation, immutable forever
  rowState: RowState;     // 0=Unchanged, 1=New, 2=Modified, 3=Deleted
  serverRowId: number;    // 0 for New rows; populated from server after save
  parentRowGuid: string | null;  // null for top-level rows; parent row's rowGuid for children
  parentServerId: number; // 0 when parent is New and has no serverRowId yet
  sortOrder: number;      // display order for tree reconstruction on crash recovery
}
```

**`rowGuid` generation rules:**
- Generate once on row creation: `crypto.randomUUID()` (or equivalent)
- Never regenerate — the UUID is the stable identity across all upserts
- Persist in the grid model for the lifetime of the session

**`serverRowId` rules:**
- New rows: always `0` until final save returns the server-assigned ID
- Modified / Deleted rows: the ID from the server (e.g., `DETAILID`, `CHARGEID`)

**`parentRowGuid` / `parentServerId` rules:**
- For charges (children of detail lines): `parentRowGuid` = the detail row's `rowGuid`
- If the parent detail was also New in this session: `parentServerId = 0` — the backend resolves the FK via `parentRowGuid`

---

## Session Management

### SessionGuid

- **Client-generated** UUID: `crypto.randomUUID()` when the form opens
- **One per form-open** — never regenerate during the session
- **Reuse** on crash recovery: read `DraftId` + `SessionGuid` from local storage → call `GET /Draft/GetDraftRows`
- Store in component state and local storage for recovery

### DraftId

- Returned by `POST /Draft/UpsertDraftHeader` on the first call
- Store and pass back in subsequent `UpsertDraftHeader` calls so the server knows it's an update
- Also store in local storage for crash recovery

---

## API Endpoints

All endpoints are on the base URL configured in the FE. All require the `Login` header (existing pattern).

---

### 1. Upsert Draft Header

**Purpose:** Sync the form-level header blob. Call on a 30-second timer and before any significant action (submit, suspend, navigate away).

```
POST /Draft/UpsertDraftHeader
Header: Login: <encrypted-login-token>
Body: DraftSessionDTO
```

**Request body (`DraftSessionDTO`):**
```typescript
interface DraftSessionDTO {
  draftId: number;            // 0 on first call; use returned value on subsequent calls
  sessionGuid: string;        // client-generated UUID, fixed for the session
  entityId: number;           // document header ID (0 if not yet saved)
  menuId: number;             // menu/screen identifier
  bizTransactionTypeId: number;
  ouId: number;
  code: string;               // document code/number (may be empty for new docs)
  name: string;
  particulars: string;
  draftNumber: string;
  jsonObject: string;         // full serialized form state JSON (header fields + metadata)
}
```

**Response:**
```typescript
interface ResponseStandard<T> {
  data: T;
  // ... standard response wrapper
}
// data:
{ draftId: number }  // use this draftId in all future calls for this session
```

**When to call:**
- On 30-second auto-save timer tick
- Immediately before calling `POST /Draft/ShareDraft`
- Immediately before navigating away (beforeunload)
- On `Suspend` action

---

### 2. Upsert Draft Row

**Purpose:** Send a single dirty grid row. High-frequency — call on every cell blur / row add / row delete event in the grid.

```
POST /Draft/UpsertDraftRow
Header: Login: <encrypted-login-token>
Body: DraftRowDTO
```

**Request body (`DraftRowDTO`):**
```typescript
interface DraftRowDTO {
  draftId: number;            // DraftId from UpsertDraftHeader response
  sessionGuid: string;
  clientId: number;           // from login context
  rowGuid: string;            // UUID for this row (immutable)
  parentRowGuid: string | null;
  objectTypeId: number;       // identifies the entity line type (e.g., MM_DETAIL=301, MM_CHARGE=302)
  rowState: RowState;         // 1=New, 2=Modified, 3=Deleted — never send 0
  serverRowId: number;
  parentServerId: number;
  sortOrder: number;
  rowData: string;            // JSON.stringify of the entity-specific fields ONLY
                              // (NOT the GridRowBase fields — those are top-level columns)
  createdOn?: string;         // ISO datetime string (set by server on first insert)
  modifiedOn?: string;        // ISO datetime string (set by server on upsert)
}
```

**`rowData` format:** JSON string of entity business fields only. Example for a purchase order line:
```json
{
  "itemCode": "RM-001",
  "itemName": "Raw Material A",
  "qty": 100,
  "unitPrice": 25.50,
  "taxCode": "GST18",
  "amount": 2550.00
}
```

**Response:** Standard success response. No data payload — errors throw.

**Rules:**
- **Never send `RowState=0` (Unchanged)** — the backend rejects it silently, but sending it wastes bandwidth
- Send immediately on grid events (debounce 300ms max)
- Deleted rows: send `RowState=3` with the current `serverRowId`; the row stays in TDRAFT_ROWS until final save
- After the first `UpsertDraftHeader` response returns a `draftId`, all row upserts must use that `draftId`

---

### 3. Get Draft Rows (Crash Recovery)

**Purpose:** Retrieve all dirty rows saved in this session. Used on crash recovery to rebuild the grid state.

```
GET /Draft/GetDraftRows?SessionGuid={sessionGuid}
Header: Login: <encrypted-login-token>
```

**Response data:**
```typescript
DraftRowDTO[]  // ordered by objectTypeId, sortOrder, draftRowId
```

**Crash recovery flow:**
1. On form load, check local storage for `{ sessionGuid, draftId, menuId }`
2. If found and matches current `menuId`, ask user: *"You have unsaved changes from a previous session. Restore?"*
3. If yes: call `GET /Draft/GetDraftRows?SessionGuid={sessionGuid}`
4. Partition rows by `objectTypeId` → rebuild each grid with `JSON.parse(row.rowData)` + `GridRowBase` fields
5. Mark restored rows with their original `rowState` — grid starts as "dirty"
6. Resume auto-save timer

---

### 4. Get Draft Pool

**Purpose:** List all active/suspended/transferred drafts for the current menu where the user is owner or assigned. Powers the "Open Draft" / sharing management screen.

```
GET /Draft/GetDraftPool?MenuId={menuId}
Header: Login: <encrypted-login-token>
```

**Response data:**
```typescript
interface DraftPoolItemDTO {
  draftId: number;
  sessionGuid: string;
  draftStatus: DraftStatus;  // 1=Active, 2=Suspended, 3=Transferred
  ownerUserId: number;
  ownerUserName: string;
  assignedToUserId: number;
  assignedToUserName: string;
  dirtyRowCount: number;     // number of rows in TDRAFT_ROWS — show as "47 rows in draft"
  lastActivityOn: string;    // ISO datetime — show as "last edited X minutes ago"
}
```

**UI hint:** Show `dirtyRowCount` as a badge and `lastActivityOn` as a relative time label.

---

### 5. Share Draft

**Purpose:** Transfer a draft to another user. The current user must be the owner and the draft must be Active.

```
POST /Draft/ShareDraft
Header: Login: <encrypted-login-token>
Body: DraftShareDTO
```

**Request body:**
```typescript
interface DraftShareDTO {
  sessionGuid: string;
  targetUserId: number;
}
```

**Response:**
- `200` — shared successfully
- `403` — caller is not the owner (or draft not in Active state). Show error message from response body.

**Pre-condition:** Call `POST /Draft/UpsertDraftHeader` first to flush the latest header state, then share.

---

### 6. Discard Draft

**Purpose:** Permanently delete the draft session. The data is gone — show a confirmation dialog before calling.

```
DELETE /Draft/DiscardDraft?SessionGuid={sessionGuid}
Header: Login: <encrypted-login-token>
```

**Response:** Standard success response.

**Post-action:** Clear `sessionGuid` and `draftId` from local storage. Reset the form.

---

## FE Session Lifecycle State Machine

```
Form opens
  │
  ├── New form → generate sessionGuid, store in state + localStorage
  │   └── First header save returns draftId → store draftId
  │
  └── Crash recovery → restore sessionGuid/draftId from localStorage
        └── GET /Draft/GetDraftRows → rebuild grid

While editing:
  ├── Cell blur / row add / row delete
  │   └── POST /Draft/UpsertDraftRow (RowState = New | Modified | Deleted)
  │
  └── Timer (30s) or significant action
      └── POST /Draft/UpsertDraftHeader

Final save:
  └── POST /{Entity}/Save{Entity}  (existing save endpoint, unchanged)
      │  (internally: backend promotes dirty rows + cleans up draft atomically)
      └── On success: clear sessionGuid/draftId from localStorage
```

---

## ObjectTypeId Convention

`objectTypeId` identifies which entity table a row belongs to. Define constants in the FE matching the backend:

```typescript
// Example for MM module — actual values defined per module
const ObjectType = {
  MM_DETAIL:  301,
  MM_CHARGE:  302,
  // PO_LINE:    401,
  // PO_CHARGE:  402,
  // SO_LINE:    501,
} as const;
```

The backend only uses `objectTypeId` for ordering/partitioning — the actual values are defined per module. Coordinate with each module's backend team for the assigned IDs.

---

## rowData Serialization Rules

- `rowData` must contain **only business fields** — not `rowGuid`, `rowState`, `serverRowId`, `parentRowGuid`, `parentServerId`, `sortOrder` (those are already top-level columns in TDRAFT_ROWS)
- Serialize with `JSON.stringify(businessFieldsOnly)`
- Null / undefined numeric fields should be `0` not `null` to match DB defaults
- Dates should be ISO 8601 strings

**Example — what NOT to include in `rowData`:**
```typescript
// WRONG — infrastructure fields must not be in rowData
const rowData = JSON.stringify({
  rowGuid: row.rowGuid,     // ← already a top-level column
  rowState: row.rowState,   // ← already a top-level column
  itemCode: row.itemCode,   // ← correct
  qty: row.qty,             // ← correct
});

// CORRECT
const rowData = JSON.stringify({
  itemCode: row.itemCode,
  itemName: row.itemName,
  qty: row.qty,
  unitPrice: row.unitPrice,
  amount: row.amount,
});
```

---

## Error Handling

| HTTP | Meaning | FE action |
|------|---------|-----------|
| `200` | Success | Continue |
| `403` | Ownership check failed (ShareDraft) | Show "You are not the owner of this draft" |
| `500` | Server error | Show generic error; do not clear draft state |

On `500` from row upsert: retry once after 2 seconds, then show a non-blocking toast "Draft sync failed — changes are safe locally". Do not block the user.

---

## Local Storage Schema

```typescript
interface DraftLocalState {
  sessionGuid: string;
  draftId: number;
  menuId: number;
  entityId: number;       // 0 for new documents
  savedAt: string;        // ISO datetime of last successful header upsert
}

// Key: `draft:${menuId}:${userId}`
// Value: JSON.stringify(DraftLocalState)
```

Clear this entry on:
- Successful final save
- User-initiated discard (`DELETE /Draft/DiscardDraft`)
- User explicitly dismisses crash-recovery prompt ("Start fresh")

---

## Draft Pool UI — Display Hints

| Field | Display |
|-------|---------|
| `draftStatus = 1` (Active) | Green badge "Active" |
| `draftStatus = 2` (Suspended) | Yellow badge "Suspended" |
| `draftStatus = 3` (Transferred) | Blue badge "Transferred to you" (when `assignedToUserId == currentUser`) |
| `dirtyRowCount` | "47 rows in draft" subtitle |
| `lastActivityOn` | "Last edited 12 minutes ago" (relative time) |
| `ownerUserName` | "Created by Venkat" |
| `assignedToUserName` | "Assigned to Ravi" |

**Resume from pool:** When user picks a draft from the pool, populate `sessionGuid` and `draftId` from the selected `DraftPoolItemDTO`, then call `GET /Draft/GetDraftRows` to rebuild the grid.

---

## Changes to Existing Entity Save Endpoints

No change to the save endpoint contract from the FE's perspective. The FE continues to call:

```
POST /{Entity}/Save{Entity}
Body: { ...headerFields, sessionGuid: "<current session>" }
```

The `sessionGuid` field must be added to all entity save request DTOs. The backend uses it to:
1. Load dirty rows from TDRAFT_ROWS
2. Promote them via the entity BLL
3. Clean up the draft atomically inside the save transaction

On success, the FE receives the existing success response (with the new entity ID). It should then clear the draft local storage entry.

---

## Summary Checklist for FE Code Generator

- [ ] Generate `rowGuid` (UUID) on every new grid row; store immutably
- [ ] Maintain `rowState` per row (0/1/2/3); never send `rowState=0` to the API
- [ ] On row add → `rowState = New (1)`; on row edit → `rowState = Modified (2)`; on row delete → `rowState = Deleted (3)`
- [ ] Include `GridRowBase` fields (`rowGuid`, `rowState`, `serverRowId`, `parentRowGuid`, `parentServerId`, `sortOrder`) in every row model
- [ ] Separate `rowData` (business fields only, JSON string) from infrastructure fields
- [ ] Generate `sessionGuid` once per form-open; store in state + localStorage
- [ ] Call `POST /Draft/UpsertDraftHeader` on 30s timer tick
- [ ] Call `POST /Draft/UpsertDraftRow` on every grid mutation (debounced 300ms)
- [ ] Implement crash recovery: check localStorage on form load → prompt → `GET /Draft/GetDraftRows`
- [ ] Pass `sessionGuid` in entity save request body
- [ ] Clear draft localStorage on successful save or discard
- [ ] Wire `GET /Draft/GetDraftPool` to the "Open Draft" picker screen for the menu
- [ ] Wire `POST /Draft/ShareDraft` to the share action (flush header first)
- [ ] Wire `DELETE /Draft/DiscardDraft` behind a confirmation dialog
- [ ] Handle `403` from ShareDraft with user-readable ownership error
- [ ] Define `ObjectTypeId` constants per module (coordinate with backend)
