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 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.
<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.
<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.
Published platform manual · revision 1b7d8410397f