<a id="recipes"></a>

# Customer workflows and KPI dashboards

## Customer and contacts workbench

The master list selects a customer. Its detail area combines a customer editor
and related contacts. The contact creation form receives the selected customer
identity from the composition, so the author does not recreate that binding for
every field. The handler source is included in [server Lua](workspace-manual://workspace-xml-4/server-lua).

```xml
<workspace id="customer-workbench" title="Customer workbench" role="Customer service" xmlns:t="urn:cococo:dataxml" domains="customer contact">
  <handler name="createContact" mode="action">
    <input><![CDATA[
      {"type":"object","additionalProperties":false,"required":["customerId","firstName","lastName"],"properties":{"customerId":{"type":"string","minLength":1},"firstName":{"type":"string","minLength":1},"lastName":{"type":"string","minLength":1},"email":{"type":["string","null"]}}}
    ]]></input>
    <output><![CDATA[
      {"type":"object","additionalProperties":false,"required":["record","errors"],"properties":{"record":{"type":["object","null"],"required":["id","firstName","lastName"],"properties":{"id":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"email":{"type":["string","null"]}}},"errors":{"type":"array","items":{"type":"object","required":["path","message"],"properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}
    ]]></output>
  </handler>
  <record-browser id="customers" title="Customers" select="selectedCustomerId"
      items="listCustomers.edges" as="customer" key="customer.node.id"
      primary="customer.node.name" secondary="customer.node.email">
    <query type="magicquery" noun="customers"><arg name="limit" value="50"/></query>
    <detail>
      <record-editor id="customer" title="Customer details" select="selectedCustomerId" record="customer" identity="id">
        <query type="graphql"><![CDATA[
          query CustomerForEdit($id: CustomerID!) {
            customer: getCustomer(id: $id) { id name email phone isActive paymentTermsDays }
          }
        ]]></query>
        <submit type="graphql" result="result.customer" errors="result.errors" error-prefix="input">
          <![CDATA[
            mutation SaveCustomer($id: CustomerID!, $input: UpdateCustomerInput!) {
              result: updateCustomer(id: $id, input: $input) {
                customer { id name email phone isActive paymentTermsDays }
                errors { path message }
              }
            }
          ]]>
        </submit>
        <section title="Customer details" columns="2">
          <field path="input.name" source="name" label="Customer name" submit="changed"/>
          <field path="input.email" source="email" label="Email" control="email" submit="changed"/>
          <field path="input.phone" source="phone" label="Phone" control="phone" submit="changed"/>
          <field path="input.isActive" source="isActive" label="Active customer" submit="changed"/>
        </section>
        <section title="Payment terms">
          <field path="input.paymentTermsDays" source="paymentTermsDays" label="Payment terms (days)" submit="changed" help="Zero means prepayment."/>
        </section>
        <form-actions save-label="Save customer" reset-label="Discard changes"/>
      </record-editor>
      <related-records id="contacts" title="People at this customer" parent-state="selectedCustomerId" parent-input="customerId"
          items="customer.contacts" as="contact">
        <query type="graphql"><![CDATA[
          query CustomerContacts($id: CustomerID!) {
            customer: getCustomer(id: $id) { contacts { id firstName lastName email jobTitle } }
          }
        ]]></query>
        <column label="First name" t:value="contact.firstName"/>
        <column label="Last name" t:value="contact.lastName"/>
        <column label="Email" t:value="contact.email"/>
        <column label="Role" t:value="contact.jobTitle"/>
      <form id="new-contact" mode="create">
        <submit type="lua" handler="createContact" result="record" errors="errors"/>
        <section title="Contact details" columns="2">
          <field path="firstName" label="First name"/>
          <field path="lastName" label="Last name"/>
          <field path="email" label="Email" control="email"/>
        </section>
        <form-actions save-label="Add contact" reset-label="Discard changes"/>
      </form>
      </related-records>
    </detail>
  </record-browser>
</workspace>
```

Use a direct GraphQL mutation for the customer patch; keep compound contact
creation or normalization in the action handler. Test switching selection with a
dirty draft, required inputs, server rejection, and a successful persisted change.
Do not replace the selected customer ID with an arbitrary text field.

## Operations KPI dashboard

This example combines commercial totals, metric summaries, time-series trends and
recent orders. Metric IDs in its source are placeholders: replace them with your
tenant's metric IDs before publishing. Labels, units and sample
counts should tell a reader what each number means and which data it represents.

```xml
<workspace id="operations-kpis" title="Operations pulse" role="Management" xmlns:t="urn:cococo:dataxml" domains="customer contact order metric">
  <!-- Preview identifiers are fixture values; replace with tenant metric IDs. -->
  <state name="throughputMetricId" default='"met_demo_throughput"'/>
  <state name="qualityMetricId" default='"met_demo_quality"'/>
  <handler name="qualitySummary" mode="read">
    <input><![CDATA[{"type":"object","additionalProperties":false,"required":["metricId"],"properties":{"metricId":{"type":"string","minLength":1}}}]]></input>
    <output><![CDATA[{"type":"object","additionalProperties":false,"required":["series"],"properties":{"series":{"type":"object","required":["count","points"],"properties":{"avg":{"type":["number","null"]},"min":{"type":["number","null"]},"max":{"type":["number","null"]},"count":{"type":"integer"},"points":{"type":"array","items":{"type":"object","required":["value"],"properties":{"value":{"type":"number"}}}}}}}}]]></output>
  </handler>
  <panel id="overview" title="Commercial activity">
    <query type="graphql"><![CDATA[
      query Overview {
        customers: listCustomers(first: 1) { totalCount }
        contacts: listContacts(first: 1) { totalCount }
        orders: listOrders(first: 1) { totalCount }
      }
    ]]></query>
    <kpi-summary id="commercial-kpis" layout="row" size="large">
      <metric label="Customers" t:value="customers.totalCount"/>
      <metric label="Contacts" t:value="contacts.totalCount"/>
      <metric label="Orders" t:value="orders.totalCount"/>
    </kpi-summary>
  </panel>
  <row>
    <metric-panel id="throughput" title="Production throughput"
      metric-state="throughputMetricId" unit="units/h" samples="24" style="area">
      <metric label="Average" stat="avg"/>
      <metric label="Peak" stat="max"/>
      <metric label="Samples" stat="count"/>
    </metric-panel>
    <panel id="quality" title="First-pass quality">
      <query type="lua" handler="qualitySummary"><arg name="metricId" state="qualityMetricId"/></query>
      <kpi-summary id="quality-kpis" size="standard">
        <metric label="Average" t:value="series.avg" unit="%"/>
        <metric label="Lowest" t:value="series.min" unit="%"/>
        <metric label="Peak" t:value="series.max" unit="%"/>
      </kpi-summary>
      <trend id="quality-trend" title="Last 24 samples" t:for="series.points" t:as="sample" t:value="sample.value" style="line"/>
    </panel>
  </row>
  <panel id="orders" title="Recent orders">
    <query type="graphql"><![CDATA[
      query Orders { connection: listOrders(first: 5) { edges { node { orderNumber status totalAmount currency } } } }
    ]]></query>
    <record-table id="orders-table" t:for="connection.edges" t:as="order">
      <column label="Order" t:value="order.node.orderNumber"/>
      <column label="Status" t:value="order.node.status" format="status"/>
      <column label="Total" t:value="order.node.totalAmount"/>
      <column label="Currency" t:value="order.node.currency"/>
    </record-table>
  </panel>
</workspace>
```

Empty series must remain valid output, with null aggregates distinguished from
zero. The Lua loader's output contract covers that distinction. An authorization
failure is an error state, not an empty metric series. Test both populated and
empty data and keep filters/selection in declared state.

## Searchable customer directory

This directory combines an editable search filter with cursor pagination and a
selected-record detail panel. Applying a search resets both the cursor and the
selection in one update. The result query accepts a null initial cursor, while the
detail panel waits for a selected ID. Previous navigation uses visited cursors in
the current browser session; changing the cursor outside the pager clears that
history. Query permissions and filters still run on the server.

```xml
<workspace id="customer-search" title="Customer directory" role="Customer service" xmlns:t="urn:cococo:dataxml" domains="customer">
  <state name="search" default='""'/>
  <state name="after" default="null"/>
  <state name="selectedCustomerId" default="null"/>
  <panel id="filters" title="Find customers">
    <query type="graphql">query { __typename }</query>
    <filter id="customer-search-input" state="search" label="Customer name" empty="string" reset-state="after selectedCustomerId"/>
  </panel>
  <master-detail>
    <panel id="customers" title="Customers" wait-for="">
      <query type="graphql">
        <arg name="search" state="search"/><arg name="after" state="after"/>
        <![CDATA[query CustomerDirectory($search:String, $after:String) {
          customers: listCustomers(first:25, after:$after, filter:{name:{contains:$search}}, sort:[{field:NAME,direction:ASC}]) {
            edges { node { id name email } }
            pageInfo { hasNextPage endCursor }
          }
        }]]>
      </query>
      <record-list id="customer-results" t:for="customers.edges" t:as="customer" key="customer.node.id" primary="customer.node.name" secondary="customer.node.email" select="selectedCustomerId"/>
      <pager id="customer-pages" panel="customers" state="after" next="customers.pageInfo.endCursor" has-next="customers.pageInfo.hasNextPage"/>
    </panel>
    <panel id="customer-detail" title="Selected customer" wait-for="id">
      <query type="graphql"><arg name="id" state="selectedCustomerId"/>
        <![CDATA[query CustomerDetail($id:CustomerID!) { customer:getCustomer(id:$id) { name email phone } }]]>
      </query>
      <record-detail id="customer-card" t:text="customer.name">
        <field label="Email" t:value="customer.email"/>
        <field label="Phone" t:value="customer.phone"/>
      </record-detail>
    </panel>
  </master-detail>
</workspace>
```

`pager` reads the named panel's `endCursor` and `hasNextPage` paths. Buttons disable
while that panel loads; Next also disables when the server reports the final page.
Use `reset-state` on related filters to avoid reusing a cursor from different query
criteria. The record-browser composite still owns only its selection/detail
layout; this recipe shows explicit panels when adding search and pagination.

For a unit returned with query data, compose a panel with `kpi-summary` and bind
its metric's `t:unit` to that response path. The convenience `metric-panel` uses a
static unit and a fixed metric-series query; use the explicit composition when
the selection also determines currency or units. Lua read handlers can return
formatted display strings, as shown in [Returning display values](workspace-manual://workspace-xml-4/server-lua).