Browse Source

Fixes #4759 - Show as an addition also accounted time by types in ticket detail.

Co-authored-by: Vladimir Sheremet <vs@zammad.com>
Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
Florian Liebe 1 year ago
parent
commit
d6caa46902

+ 42 - 2
app/assets/javascripts/app/controllers/ticket_zoom/time_unit.coffee

@@ -4,6 +4,8 @@ class App.TicketZoomTimeUnit extends App.ControllerObserver
   model: 'Ticket'
   observe:
     time_unit: true
+  events:
+    'click .js-showMoreEntries': 'showMoreEntries'
 
   constructor: ->
     super
@@ -13,11 +15,49 @@ class App.TicketZoomTimeUnit extends App.ControllerObserver
       @rerenderCallback()
     )
 
+    @showAllEntries = false
+
   render: (ticket) =>
     return if ticket.currentView() isnt 'agent'
     return if !ticket.time_unit
 
+    @ticket = ticket
+
+    entries = @fetchEntries()
+
+    list = entries.slice(0, 3)
+    if @showAllEntries
+      list = entries
+
+    # Don't show anything if there are no entries besides "none"
+    if list.length is 1 && list[0][0] is __('none')
+      list = []
+
     @html App.view('ticket_zoom/time_unit')(
-      ticket:                    ticket
-      timeAccountingDisplayUnit: @timeAccountingDisplayUnit()
+      ticket:      ticket
+      displayUnit: @timeAccountingDisplayUnit()
+      list:        list
+      showMore:    entries.length > 3 && !@showAllEntries
+    )
+
+  fetchEntries: ->
+    filtered = App.TicketTimeAccounting.search(
+      filter:
+        ticket_id: @ticket.id
+    )
+    return [] if !filtered || filtered.length is 0
+
+    types   = _.indexBy(App.TicketTimeAccountingType.all(), 'id')
+    grouped = _.groupBy(filtered, (time_accounting) -> time_accounting.type_id)
+    mapped  = _.map(grouped, (list, type_id) ->
+      iteratee = (sum, time_accounting) -> sum + parseFloat(time_accounting.time_unit)
+
+      [types[type_id]?.name || __('none'), _.reduce(list, iteratee, 0)]
     )
+
+    _.sortBy(mapped, (group) -> group[1]).reverse()
+
+  showMoreEntries: (e) ->
+    @preventDefaultAndStopPropagation(e)
+    @showAllEntries = true
+    @render(@ticket)

+ 4 - 0
app/assets/javascripts/app/models/ticket_time_accounting.coffee

@@ -0,0 +1,4 @@
+class App.TicketTimeAccounting extends App.Model
+  @configure 'TicketTimeAccounting', 'ticket_id', 'ticket_article_id', 'time_unit', 'type_id'
+  @extend Spine.Model.Ajax
+  @url: @apiPath + '/ticket/time_accountings'

+ 26 - 4
app/assets/javascripts/app/views/ticket_zoom/time_unit.jst.eco

@@ -1,9 +1,31 @@
 <div>
   <label><%- @T('Accounted Time') %></label>
   <div class="accounted-time-value">
-    <%= @ticket.time_unit %>
-    <% if @timeAccountingDisplayUnit: %>
-      <span class="text-muted"><%- @T(@timeAccountingDisplayUnit) %></span>
-    <% end %>
+    <table class="table-condensed time-accounting-types-table">
+      <tbody>
+        <tr>
+          <td><%- @T('Total') %></td>
+          <td><%= @ticket.time_unit %></td>
+          <td><% if @displayUnit: %><span class="text-muted"><%- @T(@displayUnit) %></span><% end %></td>
+        </tr>
+        <% for entry in @list: %>
+          <tr>
+            <td>
+              <span title="<%= @T(entry[0]) %>">
+                <%= @T(entry[0]).substr(0, 14) %>
+                <% if @T(entry[0]).length > 14: %>...<% end %>
+              </span>
+            </td>
+            <td><%= parseFloat(entry[1]).toFixed(1) %></td>
+            <td><% if @displayUnit: %><span class="text-muted"><%- @T(@displayUnit) %></span><% end %></td>
+          </tr>
+        <% end %>
+        <% if @showMore: %>
+          <tr>
+            <td colspan="3"><a href="#" class="js-showMoreEntries"><%- @T('show more') %></a></td>
+          </tr>
+        <% end %>
+      </tbody>
+    </table>
   </div>
 </div>

+ 9 - 0
app/assets/stylesheets/zammad.scss

@@ -14493,3 +14493,12 @@ span.is-disabled {
   @include ltr(margin-left, -6px);
   @include rtl(margin-right, -6px);
 }
+
+div.accounted-time-value .table-condensed > thead > tr > th,
+div.accounted-time-value .table-condensed > tbody > tr > th,
+div.accounted-time-value .table-condensed > tfoot > tr > th,
+div.accounted-time-value .table-condensed > thead > tr > td,
+div.accounted-time-value .table-condensed > tbody > tr > td,
+div.accounted-time-value .table-condensed > tfoot > tr > td {
+  padding: 2px;
+}

+ 39 - 2
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/TicketObjectAttributes.vue

@@ -1,11 +1,13 @@
 <!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import { computed, toRef } from 'vue'
+import { computed, toRef, ref } from 'vue'
 import type { TicketById } from '#shared/entities/ticket/types.ts'
 import { useApplicationStore } from '#shared/stores/application.ts'
 import CommonSectionMenu from '#mobile/components/CommonSectionMenu/CommonSectionMenu.vue'
 import CommonSectionMenuItem from '#mobile/components/CommonSectionMenu/CommonSectionMenuItem.vue'
+import CommonShowMoreButton from '#mobile/components/CommonShowMoreButton/CommonShowMoreButton.vue'
+import { capitalize } from '#shared/utils/formatter.ts'
 
 interface Props {
   ticket: TicketById
@@ -32,16 +34,51 @@ const timeAccountingDisplayUnit = computed(() => {
 })
 
 const isShown = toRef(() => Boolean(ticketData.value.timeUnit))
+
+const showAll = ref(false)
+const MIN_SHOWN = 3
+
+const allUnits = computed(() => {
+  return props.ticket.timeUnitsPerType || []
+})
+
+const shownUnits = computed(() => {
+  if (showAll.value) return allUnits.value
+  return allUnits.value.slice(0, MIN_SHOWN)
+})
 </script>
 
 <template>
   <CommonSectionMenu v-if="isShown">
     <CommonSectionMenuItem
       v-if="ticketData.timeUnit"
-      :label="__('Accounted Time')"
+      :label="__('Total Accounted Time')"
     >
       {{ ticketData.timeUnit }}
       {{ $t(timeAccountingDisplayUnit) }}
     </CommonSectionMenuItem>
+
+    <CommonSectionMenuItem
+      v-if="allUnits.length"
+      data-test-id="timeUnitsEntries"
+    >
+      <div class="grid grid-cols-[max-content_1fr] py-2" role="list">
+        <template
+          v-for="({ name, timeUnit }, index) of shownUnits"
+          :key="index"
+        >
+          <div class="text-white/80 max-w-[10rem] truncate rtl:ml-2 ltr:mr-2">
+            {{ capitalize($t(name)) }}
+          </div>
+          <div>{{ timeUnit }} {{ $t(timeAccountingDisplayUnit) }}</div>
+        </template>
+      </div>
+    </CommonSectionMenuItem>
+
+    <CommonShowMoreButton
+      :entities="shownUnits"
+      :total-count="allUnits.length"
+      @click="showAll = true"
+    />
   </CommonSectionMenu>
 </template>

+ 140 - 8
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/__tests__/TicketObjectAttributes.spec.ts

@@ -1,5 +1,6 @@
 // Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
 
+import { convertToGraphQLId } from '#shared/graphql/utils.ts'
 import { renderComponent } from '#tests/support/components/index.ts'
 import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
 import TicketObjectAttributes from '../TicketObjectAttributes.vue'
@@ -9,28 +10,38 @@ describe('TicketObjectAttributes', () => {
     const wrapper = renderComponent(TicketObjectAttributes, {
       props: {
         ticket: {
-          id: 1,
+          id: convertToGraphQLId('Ticket', 1),
           timeUnit: 11e-1,
+          timeUnitsPerType: [],
         },
       },
     })
 
-    const accountedTime = wrapper.getByLabelText('Accounted Time')
+    const accountedTime = wrapper.getByLabelText('Total Accounted Time')
 
     expect(accountedTime).toHaveTextContent('1.1')
+
+    expect(wrapper.queryByText('none')).not.toBeInTheDocument()
+    expect(
+      wrapper.queryByRole('button'),
+      'no "show more" button',
+    ).not.toBeInTheDocument()
   })
 
   it('does not render an empty accounted time value', () => {
     const wrapper = renderComponent(TicketObjectAttributes, {
       props: {
         ticket: {
-          id: 1,
+          id: convertToGraphQLId('Ticket', 1),
           timeUnit: 0,
+          timeUnitsPerType: null,
         },
       },
     })
 
-    expect(wrapper.queryByLabelText('Accounted Time')).not.toBeInTheDocument()
+    expect(
+      wrapper.queryByLabelText('Total Accounted Time'),
+    ).not.toBeInTheDocument()
   })
 
   it('renders the pre-defined time accounting unit', () => {
@@ -41,13 +52,14 @@ describe('TicketObjectAttributes', () => {
     const wrapper = renderComponent(TicketObjectAttributes, {
       props: {
         ticket: {
-          id: 1,
+          id: convertToGraphQLId('Ticket', 1),
           timeUnit: 11e-1,
+          timeUnitsPerType: [],
         },
       },
     })
 
-    const accountedTime = wrapper.getByLabelText('Accounted Time')
+    const accountedTime = wrapper.getByLabelText('Total Accounted Time')
 
     expect(accountedTime).toHaveTextContent('1.1 minute(s)')
   })
@@ -61,14 +73,134 @@ describe('TicketObjectAttributes', () => {
     const wrapper = renderComponent(TicketObjectAttributes, {
       props: {
         ticket: {
-          id: 1,
+          id: convertToGraphQLId('Ticket', 1),
           timeUnit: 11e-1,
+          timeUnitsPerType: [],
         },
       },
     })
 
-    const accountedTime = wrapper.getByLabelText('Accounted Time')
+    const accountedTime = wrapper.getByLabelText('Total Accounted Time')
 
     expect(accountedTime).toHaveTextContent('1.1 person day(s)')
   })
+
+  const formatTimeUnits = (element: HTMLElement) => {
+    return element.textContent?.split(')').join(')\n')
+  }
+
+  it('renders a list of time unit entries', () => {
+    mockApplicationConfig({
+      time_accounting_unit: 'minute',
+    })
+
+    const wrapper = renderComponent(TicketObjectAttributes, {
+      props: {
+        ticket: {
+          id: convertToGraphQLId('Ticket', 1),
+          timeUnit: 11e-1 + 101e-1 + 21e-1 + 300e-1 + 20e-1, // 45.3
+          timeUnitsPerType: [
+            {
+              id: convertToGraphQLId('TicketTimeUnitEntry', 2),
+              timeUnit: 101e-1 + 21e-1 + 300e-1,
+              name: 'billable',
+            },
+            {
+              id: convertToGraphQLId('TicketTimeUnitEntry', 3),
+              timeUnit: 20e-1,
+              name: 'not billable',
+            },
+            {
+              id: convertToGraphQLId('TicketTimeUnitEntry', 1),
+              timeUnit: 11e-1,
+              name: 'None',
+            },
+          ],
+        },
+      },
+    })
+
+    const entriesElement = wrapper.getByTestId('timeUnitsEntries')
+    // correctly sorted with highest at the top
+    expect(formatTimeUnits(entriesElement)).toMatchInlineSnapshot(
+      `
+      "Billable42.2 minute(s)
+      Not billable2 minute(s)
+      None1.1 minute(s)
+      "
+    `,
+    )
+
+    expect(
+      wrapper.queryByRole('button'),
+      'no "show more" button',
+    ).not.toBeInTheDocument()
+  })
+
+  it('shows a button to show more', async () => {
+    mockApplicationConfig({
+      time_accounting_unit: 'minute',
+    })
+
+    const view = renderComponent(TicketObjectAttributes, {
+      props: {
+        ticket: {
+          id: convertToGraphQLId('Ticket', 1),
+          timeUnit: 11e-1,
+          timeUnitsPerType: [
+            {
+              id: convertToGraphQLId('TicketTimeUnitEntry', 2),
+              timeUnit: 101e-1,
+              name: 'name I - ',
+            },
+            {
+              id: convertToGraphQLId('TicketTimeUnitEntry', 4),
+              timeUnit: 40e-1,
+              name: 'name III - ',
+            },
+            {
+              id: convertToGraphQLId('TicketTimeUnitEntry', 5),
+              timeUnit: 30e-1,
+              name: 'name IV - ',
+            },
+            {
+              id: convertToGraphQLId('TicketTimeUnitEntry', 3),
+              timeUnit: 20e-1,
+              name: 'name II - ',
+            },
+            {
+              id: convertToGraphQLId('TicketTimeUnitEntry', 1),
+              timeUnit: 11e-1,
+              name: 'None',
+            },
+          ],
+        },
+      },
+    })
+
+    const entriesElement = view.getByTestId('timeUnitsEntries')
+    expect(formatTimeUnits(entriesElement)).toMatchInlineSnapshot(
+      `
+      "Name I - 10.1 minute(s)
+      Name III - 4 minute(s)
+      Name IV - 3 minute(s)
+      "
+    `,
+    )
+
+    const buttonShowMore = view.getByRole('button', { name: 'Show 2 more' })
+
+    await view.events.click(buttonShowMore)
+
+    expect(formatTimeUnits(entriesElement)).toMatchInlineSnapshot(
+      `
+      "Name I - 10.1 minute(s)
+      Name III - 4 minute(s)
+      Name IV - 3 minute(s)
+      Name II - 2 minute(s)
+      None1.1 minute(s)
+      "
+    `,
+    )
+  })
 })

+ 4 - 0
app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketAttributes.api.ts

@@ -80,6 +80,10 @@ export const TicketAttributesFragmentDoc = gql`
   }
   tags
   timeUnit
+  timeUnitsPerType {
+    name
+    timeUnit
+  }
   subscribed
   preferences
   stateColorCode

+ 4 - 0
app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketAttributes.graphql

@@ -75,6 +75,10 @@ fragment ticketAttributes on Ticket {
   }
   tags
   timeUnit
+  timeUnitsPerType {
+    name
+    timeUnit
+  }
   subscribed
   preferences
   stateColorCode

File diff suppressed because it is too large
+ 8 - 0
app/frontend/shared/graphql/types.ts


+ 4 - 0
app/frontend/shared/utils/formatter.ts

@@ -39,6 +39,10 @@ export const camelize = (str: string) => {
   return str.replace(/[_.-](\w|$)/g, (_, x) => x.toUpperCase())
 }
 
+export const capitalize = (str: string) => {
+  return str[0].toUpperCase() + str.slice(1)
+}
+
 export const toClassName = (str: string) => {
   return str.replace(
     /([a-z])([A-Z])/g,

Some files were not shown because too many files changed in this diff