{
  "id": "TD03",
  "title": "Inline approval",
  "topic": "tables",
  "use": "Requests are approved or rejected in the table row. What counts for the decision stands in the row — who from the team is already absent in the same period, and whether the team would then be understaffed. Approving is one click and can be taken back; rejecting asks for the reason. Use the pattern as long as a glance is enough for the decision.",
  "decisions": [
    "The decision stands in the row because one look is enough — who, when, and who else is absent at the same time. A case that needs history or a conversation is a queue with detail, not this pattern.",
    "The collision stands in the row: the badge '3/4 absent' on the open request, and the names of the others in the cell. The badge says what it counts in two words; its title says it in a sentence. The threshold is half the team, and it is a number and not a red icon, because two of two and three of eight are different situations. Only approved absences count; an open request is no absence yet.",
    "Approving is one click with no dialog, because it is the expected answer and can be taken back. Rejecting and asking a question open a dialog that asks for the text and says who reads it.",
    "Taking back is a row action, not a step back: a decided row carries Undo instead of Approve and returns to Received. Only deleting, which does not come back, gets an alert dialog that names the request and says it cannot be undone.",
    "One action is visible and the rest sit in the More menu: approving is the daily action, rejecting needs its dialog anyway, and the rare and destructive entries come last behind a divider with their full labels. The price is one click more for rejecting.",
    "No selection and no toolbar actions: the same action never stands in two places, and approving many at once is a bulk case of its own. The toolbar carries only the search and the quick filter.",
    "The status is a badge in the past tense — Received, Queried, Approved, Rejected — with the reason in its title, because a badge does not explain its state.",
    "How long a request waits stands in the row only from two weeks on and only while it is open, and the list is sorted by receipt, oldest first.",
    "One notification above the table counts the understaffed teams in a sentence; it goes with the last conflict and returns with the next. The row is the confirmation, and a toast appears only where the row leaves the view."
  ],
  "try": [
    {
      "do": "Look at the row of Deniz Aydın",
      "then": "Absent at the same time names Sophie Brandt and Katharina Petry, and the badge says 3/4 absent"
    },
    {
      "do": "Press Approve",
      "then": "the status turns to Approved, the actions become Undo, no toast appears, and the notification counts one conflict less"
    },
    {
      "do": "Press Undo",
      "then": "the request stands at Received again, and the badge at the team is back"
    },
    {
      "do": "Open More for Jonas Weißgerber and choose Reject the request from Jonas Weißgerber…",
      "then": "the dialog names the request and asks for the reason"
    },
    {
      "do": "Press Reject request without a text",
      "then": "the message says to give the reason, and that Jonas Weißgerber sees it in the request"
    },
    {
      "do": "Open More for Lea Schumacher",
      "then": "Reject and Delete stand there with their full labels, Delete behind the divider"
    },
    {
      "do": "Choose Delete the request from Lea Schumacher…",
      "then": "the alert dialog names the request and says that this cannot be undone"
    },
    {
      "do": "Choose it again and click beside the question",
      "then": "the question closes as Cancel does, and the request stays"
    },
    {
      "do": "Set the quick filter to Open and approve a request",
      "then": "the row disappears, and a toast says what happened"
    },
    {
      "do": "Click Received in the header",
      "then": "the order turns, and the arrow comes from the component"
    },
    {
      "do": "Type “Payroll” in the search",
      "then": "two requests remain, and the counter reads “2 of 10 entries”"
    }
  ],
  "revision": 1,
  "changes": [],
  "reviewed": true,
  "source": "<!-- Demo only, not part of the pattern. The table takes the height the screen leaves it; here it is set to fit the frame. -->\n\n<div class=\"ed-stack ed-gap-4\">\n  <ed-notification id=\"td03-conflicts\" variant=\"warning\" closable open>\n    <span id=\"td03-conflicts-title\"></span>\n    <span slot=\"description\" id=\"td03-conflicts-text\"></span>\n  </ed-notification>\n\n  <ed-data-grid id=\"td03-grid\" label=\"Absence requests\" with-search data-ed-preload=\"ed-format-date\">\n    <ed-radio-group slot=\"toolbar-view\" id=\"td03-quick\" appearance=\"button\" aria-label=\"Requests by status\" value=\"all\">\n      <ed-radio value=\"open\">Open</ed-radio>\n      <ed-radio value=\"decided\">Decided</ed-radio>\n      <ed-radio value=\"all\">All</ed-radio>\n    </ed-radio-group>\n    <ed-empty-state slot=\"no-results\" size=\"medium\" icon=\"search\">\n      <span slot=\"label\">No request matches the search and quick filter</span>\n      <span id=\"td03-empty-text\"></span>\n      <ed-button slot=\"button\" id=\"td03-empty-reset\" appearance=\"outlined\">\n        <ed-icon slot=\"start\" name=\"close\"></ed-icon>\n        Reset search and quick filter\n      </ed-button>\n    </ed-empty-state>\n  </ed-data-grid>\n\n  <p id=\"td03-status\" role=\"status\"></p>\n\n  <ed-dialog id=\"td03-reason-dialog\" light-dismiss label=\"Reject the request\" style=\"--ed-dialog-width: 36rem\">\n    <div class=\"ed-stack ed-gap-4\">\n      <p id=\"td03-reason-request\"></p>\n      <ed-textarea id=\"td03-reason\" label=\"Reason\" rows=\"3\" required autofocus></ed-textarea>\n    </div>\n    <ed-button slot=\"footer\" id=\"td03-reason-submit\">Reject request</ed-button>\n    <ed-button slot=\"footer\" appearance=\"outlined\" data-dialog=\"close\">Cancel</ed-button>\n  </ed-dialog>\n\n  <ed-alert-dialog id=\"td03-discard\" light-dismiss type=\"warning\" label=\"Discard the reason\" confirm-text=\"Discard reason\" cancel-text=\"Keep writing\">\n    The text entered is lost.\n  </ed-alert-dialog>\n\n  <ed-alert-dialog id=\"td03-delete\" light-dismiss type=\"warning\" label=\"Delete the request\" confirm-text=\"Delete the request\" cancel-text=\"Cancel\">\n    <span id=\"td03-delete-text\"></span>\n  </ed-alert-dialog>\n\n  <ed-toast-stack id=\"td03-toasts\"></ed-toast-stack>\n</div>\n\n<!-- Demo only, not part of the pattern. The product supplies TODAY, the current date, and HOLIDAYS, the public holidays that are no working days, both as YYYY-MM-DD. -->\n\n<!-- Demo only, not part of the pattern. The product supplies people, the staff as a Map from id to { id, name, team, start, end, leaveUntil }; absences, the approved absences as { person, type, from, to }; and requests, the rows as { id, person, name, team, type, from, to, received, status, note } with status received, queried, approved or rejected. All dates are YYYY-MM-DD; end and leaveUntil are empty where they do not apply. -->\n\n<script type=\"module\">\n  const grid = document.getElementById('td03-grid');\n  const quick = document.getElementById('td03-quick');\n  const conflicts = document.getElementById('td03-conflicts');\n  const status = document.getElementById('td03-status');\n  const reasonDialog = document.getElementById('td03-reason-dialog');\n  const reason = document.getElementById('td03-reason');\n  const submit = document.getElementById('td03-reason-submit');\n  const discard = document.getElementById('td03-discard');\n  const removal = document.getElementById('td03-delete');\n  const toasts = document.getElementById('td03-toasts');\n\n  const LONG_WAIT = 14;\n  const STATUS = {\n    received: { label: 'Received', variant: 'neutral', open: true },\n    queried: { label: 'Queried', variant: 'warning', open: true },\n    approved: { label: 'Approved', variant: 'success', open: false },\n    rejected: { label: 'Rejected', variant: 'danger', open: false }\n  };\n  const QUICK = { open: ['received', 'queried'], decided: ['approved', 'rejected'] };\n\n  const day = value =>\n    Object.assign(document.createElement('ed-format-date'), { date: value, month: 'short', day: 'numeric', year: 'numeric' });\n  // A period stays text: ed-format-date writes no ranges, and the period also stands in the Request\n  // column's value, which the search reads, and in a title.\n  const date = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' });\n  const period = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });\n  const DAY = 86400000;\n  const days = (from, to) => Math.round((Date.parse(to) - Date.parse(from)) / DAY);\n  const range = ({ from, to }) => (from === to ? date.format(new Date(from)) : period.formatRange(new Date(from), new Date(to)));\n\n  const isOpen = request => STATUS[request.status].open;\n  const count = (number, one, many) => `${number} ${number === 1 ? one : many}`;\n  const overlaps = (a, b) => a.from <= b.to && b.from <= a.to;\n\n  // Monday to Friday, public holidays left out.\n  function workingDays({ from, to }) {\n    let total = 0;\n    for (let day = Date.parse(from); day <= Date.parse(to); day += DAY) {\n      const iso = new Date(day).toISOString().slice(0, 10);\n      if (![0, 6].includes(new Date(day).getUTCDay()) && !HOLIDAYS.includes(iso)) total += 1;\n    }\n    return total;\n  }\n\n  const describe = request => `${request.type}, ${range(request)}, ${count(workingDays(request), 'working day', 'working days')}`;\n\n  // Who belongs to the team in the period: started, not yet ended, not on parental leave.\n  const teamIn = (team, span) =>\n    [...people.values()].filter(\n      person => person.team === team && person.start <= span.to && !(person.end && person.end < span.from) && !(person.leaveUntil && person.leaveUntil >= span.from)\n    );\n\n  // Approved absences and approved requests count; open requests are no absence yet. A team is\n  // understaffed once half of it is missing with this request and someone else is already away.\n  function absence(request) {\n    const team = teamIn(request.team, request);\n    const away = team\n      .filter(person => person.id !== request.person)\n      .map(person => ({\n        person,\n        span:\n          absences.find(other => other.person === person.id && overlaps(other, request)) ??\n          requests.find(other => other.person === person.id && other.status === 'approved' && overlaps(other, request))\n      }))\n      .filter(({ span }) => span);\n    const missing = away.length + 1;\n    return { away, missing, size: team.length, understaffed: away.length >= 1 && missing * 2 >= team.length };\n  }\n\n  function absentCell(value, request) {\n    const { away, missing, size, understaffed } = absence(request);\n    if (!away.length) return '—';\n    const wrapper = document.createElement('span');\n    // The badge leads: the cell is one line, and what stands at its end is cut off first.\n    if (understaffed && isOpen(request)) {\n      const badge = document.createElement('ed-badge');\n      badge.variant = 'warning';\n      badge.title = `With this request, ${missing} of ${size} in team ${request.team} are absent`;\n      badge.textContent = `${missing}/${size} absent`;\n      wrapper.append(badge, ' ');\n    }\n    away.forEach(({ person, span }, index) => {\n      const name = document.createElement('span');\n      name.title = `${span.type} ${range(span)}`;\n      name.textContent = person.name;\n      wrapper.append(name, index < away.length - 1 ? ', ' : '');\n    });\n    return wrapper;\n  }\n\n  function receivedCell(value, request) {\n    const waiting = days(value, TODAY);\n    if (!isOpen(request) || waiting < LONG_WAIT) return day(value);\n    const wrapper = document.createElement('span');\n    wrapper.append(day(value), ` · open for ${waiting} days`);\n    return wrapper;\n  }\n\n  function statusCell(value, request) {\n    const badge = document.createElement('ed-badge');\n    badge.variant = STATUS[value].variant;\n    badge.textContent = STATUS[value].label;\n    if (request.note) badge.title = request.note;\n    return badge;\n  }\n\n  function button(label, icon, name, action) {\n    const element = document.createElement('ed-button');\n    const start = document.createElement('ed-icon');\n    start.slot = 'start';\n    start.name = icon;\n    element.append(start, label);\n    element.setAttribute('appearance', 'plain');\n    element.setAttribute('density', 'compact');\n    element.setAttribute('aria-label', name);\n    element.addEventListener('click', action);\n    return element;\n  }\n\n  function menuItem(value, label) {\n    const item = document.createElement('ed-dropdown-item');\n    item.value = value;\n    item.textContent = label;\n    return item;\n  }\n\n  const actions = new Map();\n  const actionKey = request => `${request.id}:${request.status}`;\n\n  function actionsCell(value, request) {\n    const key = actionKey(request);\n    if (actions.has(key)) return actions.get(key);\n\n    const wrapper = document.createElement('span');\n    const primary = isOpen(request)\n      ? button('Approve', 'check', `Approve the request from ${request.name}`, () => approve(request))\n      : button('Undo', 'undo', `Undo the decision on the request from ${request.name}`, () => takeBack(request));\n\n    const menu = document.createElement('ed-dropdown');\n    const trigger = document.createElement('ed-button');\n    trigger.slot = 'trigger';\n    trigger.textContent = 'More';\n    trigger.setAttribute('appearance', 'plain');\n    trigger.setAttribute('density', 'compact');\n    trigger.setAttribute('with-caret', '');\n    trigger.setAttribute('aria-label', `More actions for the request from ${request.name}`);\n    menu.append(trigger);\n    if (isOpen(request)) {\n      menu.append(menuItem('reject', `Reject the request from ${request.name}…`));\n      if (request.status !== 'queried') menu.append(menuItem('query', `Ask ${request.name} a question…`));\n      menu.append(document.createElement('ed-divider'));\n    }\n    menu.append(menuItem('delete', `Delete the request from ${request.name}…`));\n    menu.addEventListener('ed-dropdown-select', event => {\n      const choice = event.detail.item.value;\n      if (choice === 'delete') confirmDelete(request);\n      else askReason(request, choice);\n    });\n\n    wrapper.append(primary, ' ', menu);\n    actions.set(key, wrapper);\n    return wrapper;\n  }\n\n  grid.columns = [\n    { field: 'name', label: 'Employee', rowheader: true, flex: 1, minWidth: 150 },\n    { field: 'team', label: 'Team', width: 140 },\n    {\n      id: 'request',\n      label: 'Request',\n      value: describe,\n      comparator: (a, b, rowA, rowB) => rowA.from.localeCompare(rowB.from),\n      flex: 2,\n      minWidth: 260\n    },\n    { id: 'absent', label: 'Absent at the same time', value: request => absence(request).away.map(({ person }) => person.name).join(', '), searchable: false, sortable: false, flex: 2, minWidth: 220, formatter: absentCell },\n    { field: 'received', label: 'Received', width: 210, sortFn: 'datetime', formatter: receivedCell },\n    {\n      field: 'status',\n      label: 'Status',\n      width: 120,\n      sortable: false,\n      filterable: true,\n      filterType: 'set',\n      filterOptions: Object.entries(STATUS).map(([value, { label }]) => ({ value, label })),\n      formatter: statusCell\n    },\n    { id: 'actions', label: 'Actions', align: 'end', width: 190, pinned: 'right', formatter: actionsCell }\n  ];\n  grid.sort = [{ id: 'received', desc: false }];\n\n  function render(focusRequest) {\n    grid.data = requests;\n\n    const understaffed = requests.filter(request => isOpen(request) && absence(request).understaffed);\n    document.getElementById('td03-conflicts-title').textContent =\n      understaffed.length === 1\n        ? '1 open request falls in a week in which the team would be understaffed'\n        : `${understaffed.length} open requests fall in weeks in which the team would be understaffed`;\n    document.getElementById('td03-conflicts-text').textContent = understaffed\n      .map(request => {\n        const { missing, size } = absence(request);\n        return `${request.name} (${request.team}, ${range(request)}: ${missing} of ${size} absent)`;\n      })\n      .join(' · ');\n    conflicts.open = understaffed.length > 0;\n    document.getElementById('td03-empty-text').textContent =\n      `There are ${count(requests.length, 'request', 'requests')} — just none that matches the search and quick filter.`;\n\n    if (focusRequest) {\n      requestAnimationFrame(() => {\n        const wrapper = actions.get(actionKey(focusRequest));\n        if (wrapper?.isConnected) wrapper.firstElementChild.focus();\n      });\n    }\n  }\n\n  // The row is the confirmation; a toast says what happened only where the row leaves the view.\n  async function settle(request, done) {\n    status.textContent = done;\n    render(request);\n    await grid.updateComplete;\n    if (!grid.getProcessedRows().includes(request)) {\n      toasts.create({ variant: 'success', title: done, description: 'The row is outside the current view.' });\n    }\n  }\n\n  function approve(request) {\n    request.status = 'approved';\n    request.note = '';\n    settle(request, `Request from ${request.name} approved.`);\n  }\n\n  function takeBack(request) {\n    const before = STATUS[request.status].label;\n    request.status = 'received';\n    request.note = '';\n    settle(request, `Request from ${request.name} set back to Received — previously ${before}.`);\n  }\n\n  let pending = null;\n  let allowClose = false;\n\n  function askReason(request, kind) {\n    const rejecting = kind === 'reject';\n    pending = { request, kind, decided: false };\n    allowClose = false;\n    reasonDialog.label = rejecting ? `Reject the request from ${request.name}` : `Ask ${request.name} a question`;\n    document.getElementById('td03-reason-request').replaceChildren(\n      `${request.type} ${range(request)}, ${count(workingDays(request), 'working day', 'working days')}, received on `,\n      day(request.received),\n      '.'\n    );\n    reason.label = rejecting ? 'Reason' : 'Question';\n    reason.hint = rejecting\n      ? `${request.name} sees the reason in the request.`\n      : `${request.name} gets the question and answers in the request.`;\n    reason.value = '';\n    reason.error = null;\n    submit.textContent = rejecting ? 'Reject request' : 'Send question';\n    reasonDialog.open = true;\n  }\n\n  submit.addEventListener('click', () => {\n    const { request, kind } = pending;\n    if (!reason.value.trim()) {\n      reason.error =\n        kind === 'reject'\n          ? `Give the reason. ${request.name} sees it in the request.`\n          : `Write the question. ${request.name} gets it as written here.`;\n      reason.focus();\n      return;\n    }\n    pending.decided = true;\n    allowClose = true;\n    reasonDialog.open = false;\n    request.status = kind === 'reject' ? 'rejected' : 'queried';\n    request.note = reason.value.trim();\n  });\n\n  reason.addEventListener('input', () => {\n    if (reason.value.trim()) reason.error = null;\n  });\n\n  reasonDialog.addEventListener('ed-hide', event => {\n    if (event.target !== reasonDialog || allowClose || !reason.value.trim()) return;\n    event.preventDefault();\n    discard.open = true;\n  });\n\n  reasonDialog.addEventListener('ed-after-hide', () => {\n    if (!pending?.decided) return;\n    const { request, kind } = pending;\n    settle(request, kind === 'reject' ? `Request from ${request.name} rejected.` : `Question to ${request.name} sent.`);\n  });\n\n  discard.addEventListener('ed-alert-dialog-confirm', () => {\n    allowClose = true;\n  });\n\n  discard.addEventListener('ed-after-hide', () => {\n    if (allowClose) reasonDialog.open = false;\n  });\n\n  // Deleting is the one step that does not go back, and the only one with an alert dialog.\n  let removing = null;\n  let neighbor = null;\n\n  function confirmDelete(request) {\n    removing = request;\n    neighbor = null;\n    removal.label = `Delete the request from ${request.name}`;\n    removal.confirmText = `Delete the request from ${request.name}`;\n    document.getElementById('td03-delete-text').replaceChildren(\n      `${request.type} ${range(request)}, received on `,\n      day(request.received),\n      ', is removed. This cannot be undone.'\n    );\n    removal.open = true;\n  }\n\n  removal.addEventListener('ed-alert-dialog-confirm', () => {\n    const shown = grid.getProcessedRows();\n    const index = shown.indexOf(removing);\n    neighbor = shown[index + 1] ?? shown[index - 1] ?? null;\n    requests.splice(requests.indexOf(removing), 1);\n    status.textContent = `Request from ${removing.name} deleted.`;\n    render();\n  });\n\n  // The menu the focus came from is gone with its row; the focus goes on to the row next to it.\n  removal.addEventListener('ed-after-hide', () => {\n    if (neighbor) render(neighbor);\n  });\n\n  quick.addEventListener('change', () => {\n    const others = grid.filters.filter(filter => filter.id !== 'status');\n    grid.filters = QUICK[quick.value] ? [...others, { id: 'status', value: QUICK[quick.value] }] : others;\n  });\n\n  grid.addEventListener('ed-data-grid-filter-change', event => {\n    const filter = event.detail.filters.find(entry => entry.id === 'status');\n    const values = [filter?.value ?? []].flat();\n    const match = Object.keys(QUICK).find(key => QUICK[key].length === values.length && QUICK[key].every(value => values.includes(value)));\n    quick.value = values.length === 0 ? 'all' : (match ?? '');\n  });\n\n  document.getElementById('td03-empty-reset').addEventListener('click', () => {\n    grid.searchTerm = '';\n    grid.filters = grid.filters.filter(filter => filter.id !== 'status');\n    quick.value = 'all';\n  });\n\n  render();\n</script>",
  "page": "https://test-apps.eurodata.de/a/eurodata-ui/docs/patterns/tables/td03-inline-approval/",
  "topicPage": "https://test-apps.eurodata.de/a/eurodata-ui/docs/patterns/tables/"
}
