您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 
 

400 行
22 KiB

  1. {# templates/report/times.html.twig #}
  2. {% extends 'base.html.twig' %}
  3. {% set monthsShort = deMonthsShort() %}
  4. {% block title %}{{ 'app.report.page_title'|trans }}{% endblock %}
  5. {% block javascripts %}
  6. {{ parent() }}
  7. {{ encore_entry_script_tags('report') }}
  8. {% endblock %}
  9. {% block body %}
  10. <script>
  11. window.Report = {
  12. trackingInterval: {{ trackingInterval }},
  13. currentUserId: {{ currentUserId }},
  14. isAdmin: {{ isAdmin ? 'true' : 'false' }},
  15. isTracker: {{ isTracker ? 'true' : 'false' }},
  16. limit: {{ limit }},
  17. clients: [
  18. {% for client in clients %}
  19. { id: {{ client.id }}, name: {{ client.name|json_encode|raw }} }{% if not loop.last %},{% endif %}
  20. {% endfor %}
  21. ],
  22. projects: [
  23. {% for project in projects %}
  24. {
  25. id: {{ project.id }},
  26. name: {{ project.name|json_encode|raw }},
  27. clientName: {{ project.client.name|json_encode|raw }} }{% if not loop.last %},{% endif %}
  28. {% endfor %}
  29. ],
  30. services: [
  31. {% for service in services %}
  32. {
  33. id: {{ service.id }},
  34. name: {{ service.name|json_encode|raw }},
  35. billable: {{ service.billable ? 'true' : 'false' }} }{% if not loop.last %},{% endif %}
  36. {% endfor %}
  37. ],
  38. users: {{ userList|json_encode|raw }},
  39. i18n: {
  40. btnSave: {{ 'app.entry.btn_save'|trans|json_encode|raw }},
  41. btnCancel: {{ 'app.entry.btn_cancel'|trans|json_encode|raw }},
  42. btnEdit: {{ 'app.entry.btn_edit'|trans|json_encode|raw }},
  43. btnDelete: {{ 'app.entry.btn_delete'|trans|json_encode|raw }},
  44. confirmDelete: {{ 'app.entry.confirm_delete'|trans|json_encode|raw }},
  45. errorSave: {{ 'app.entry.error_save'|trans|json_encode|raw }},
  46. errorDelete: {{ 'app.entry.error_delete'|trans|json_encode|raw }},
  47. errorNoProject: {{ 'app.entry.error_no_project'|trans|json_encode|raw }},
  48. errorZeroDuration: {{ 'app.entry.error_zero_duration'|trans|json_encode|raw }},
  49. errorDurationTooLong:{{ 'app.entry.error_duration_too_long'|trans|json_encode|raw }},
  50. warnDurationLong: {{ 'app.entry.warn_duration_long'|trans|json_encode|raw }},
  51. billable: {{ 'app.service.billable'|trans|json_encode|raw }},
  52. notBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }},
  53. selectPh: {{ 'app.entry.select_placeholder'|trans|json_encode|raw }},
  54. btnLock: {{ 'app.report.btn_lock'|trans|json_encode|raw }},
  55. btnUnlock: {{ 'app.report.btn_unlock'|trans|json_encode|raw }},
  56. invoiceCreating: {{ 'app.report.invoice_creating'|trans|json_encode|raw }},
  57. invoiceSuccess: {{ 'app.report.invoice_success'|trans|json_encode|raw }},
  58. invoiceError: {{ 'app.report.invoice_error'|trans|json_encode|raw }},
  59. invoiceOpen: {{ 'app.report.invoice_open'|trans|json_encode|raw }},
  60. invoiceModalTitle: {{ 'app.report.invoice_modal_title'|trans|json_encode|raw }},
  61. invoiceGroupLabel: {{ 'app.report.invoice_group_label'|trans|json_encode|raw }},
  62. invoiceGroupService: {{ 'app.report.invoice_group_service'|trans|json_encode|raw }},
  63. invoiceGroupProject: {{ 'app.report.invoice_group_project'|trans|json_encode|raw }},
  64. invoiceGroupByLabel: {{ 'app.report.invoice_group_by_label'|trans|json_encode|raw }},
  65. invoiceColName: {{ 'app.report.invoice_col_name'|trans|json_encode|raw }},
  66. invoiceColHours: {{ 'app.report.invoice_col_hours'|trans|json_encode|raw }},
  67. invoiceColUnit: {{ 'app.report.invoice_col_unit'|trans|json_encode|raw }},
  68. invoiceColRate: {{ 'app.report.invoice_col_rate'|trans|json_encode|raw }},
  69. invoiceColTotal: {{ 'app.report.invoice_col_total'|trans|json_encode|raw }},
  70. invoiceUnitHour: {{ 'app.report.invoice_unit_hour'|trans|json_encode|raw }},
  71. invoiceBtnCreate: {{ 'app.report.invoice_btn_create'|trans|json_encode|raw }},
  72. invoiceLoading: {{ 'app.report.invoice_loading'|trans|json_encode|raw }},
  73. invoiceNoItems: {{ 'app.report.invoice_no_items'|trans|json_encode|raw }},
  74. }
  75. };
  76. </script>
  77. <div class="report-page">
  78. <div class="report-header">
  79. <h1 class="report-header__title">{{ 'app.report.heading'|trans }}</h1>
  80. <div class="report-header__right">
  81. <a href="{{ path('report_statistics') }}" class="report-stats-bubble">
  82. <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" class="report-stats-bubble__icon">
  83. <rect x="2" y="10" width="4" height="8" rx="1" stroke="currentColor" stroke-width="1.3"/>
  84. <rect x="8" y="6" width="4" height="12" rx="1" stroke="currentColor" stroke-width="1.3"/>
  85. <rect x="14" y="2" width="4" height="16" rx="1" stroke="currentColor" stroke-width="1.3"/>
  86. </svg>
  87. {{ 'app.report.tab_statistics'|trans }}
  88. </a>
  89. <nav class="account-tabs">
  90. <a href="{{ path('report_times') }}"
  91. class="account-tab account-tab--active">
  92. {{ 'app.report.tab_times'|trans }}
  93. </a>
  94. <span class="account-tab account-tab--disabled">
  95. {{ 'app.report.tab_projects'|trans }}
  96. </span>
  97. </nav>
  98. </div>
  99. </div>
  100. <div class="report-content">
  101. <div class="report-card">
  102. {# ── Toolbar ──────────────────────────────────────────────────────── #}
  103. <div class="report-toolbar">
  104. <div class="report-toolbar__left">
  105. <button class="report-toolbar__action{% if filterActive %} report-toolbar__action--active{% endif %}"
  106. id="btn-filter-toggle"
  107. type="button">
  108. <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
  109. <circle cx="6.5" cy="6.5" r="4" stroke="currentColor" stroke-width="1.3"/>
  110. <path d="M11 11l2.5 2.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
  111. </svg>
  112. {{ 'app.report.toolbar_filter'|trans }}
  113. </button>
  114. </div>
  115. <div class="report-toolbar__right">
  116. {% if lexofficeInvoice %}
  117. <button class="report-toolbar__export"
  118. id="btn-lexoffice-invoice"
  119. type="button"
  120. title="{{ 'app.report.create_invoice'|trans }}"
  121. data-contact-id="{{ lexofficeInvoice.contactId }}"
  122. data-client-name="{{ lexofficeInvoice.clientName }}"
  123. data-date-from="{{ lexofficeInvoice.dateFrom }}"
  124. data-date-to="{{ lexofficeInvoice.dateTo }}">
  125. {% include '_atoms/icon-invoice.html.twig' %}
  126. </button>
  127. <span class="report-toolbar__separator"></span>
  128. {% endif %}
  129. <button class="report-toolbar__export"
  130. id="btn-export-excel"
  131. type="button"
  132. title="{{ 'app.report.export_excel'|trans }}">
  133. {% include '_atoms/icon-excel.html.twig' %}
  134. </button>
  135. <button class="report-toolbar__export"
  136. id="btn-export-csv"
  137. type="button"
  138. title="{{ 'app.report.export_csv'|trans }}">
  139. {% include '_atoms/icon-csv.html.twig' %}
  140. </button>
  141. <button class="report-toolbar__export"
  142. id="btn-export-pdf"
  143. type="button"
  144. title="{{ 'app.report.export_pdf'|trans }}">
  145. {% include '_atoms/icon-pdf.html.twig' %}
  146. </button>
  147. <span class="report-toolbar__separator"></span>
  148. <button class="report-toolbar__export"
  149. id="btn-print"
  150. type="button"
  151. title="{{ 'app.report.print'|trans }}">
  152. {% include '_atoms/icon-print.html.twig' %}
  153. </button>
  154. </div>
  155. </div>
  156. {# ── Filter-Panel ─────────────────────────────────────────────────── #}
  157. {% include 'report/_filter-panel.html.twig' %}
  158. {# ── Tabellen-Header ───────────────────────────────────────────────── #}
  159. {% macro sort_header(col, label, sort, dir, extra) %}
  160. {% set active = sort == col %}
  161. {% set nextDir = (active and dir == 'ASC') ? 'DESC' : 'ASC' %}
  162. <div class="report-table__cell report-table__cell--{{ col }} report-table__cell--sortable{% if active %} report-table__cell--sorted{% endif %}"
  163. data-sort="{{ col }}" data-dir="{{ nextDir }}">
  164. <span class="report-table__cell-label">{{ label }}{% if active %}<span class="report-table__sort-icon">{{ dir == 'ASC' ? '▴' : '▾' }}</span>{% endif %}</span>
  165. {% if extra %}<span class="report-table__summary">{{ extra }}</span>{% endif %}
  166. </div>
  167. {% endmacro %}
  168. <div class="report-table">
  169. <div class="report-table__head">
  170. {{ _self.sort_header('date', 'app.report.col_date'|trans, sort, dir, '') }}
  171. {{ _self.sort_header('client', 'app.report.col_client'|trans, sort, dir, '') }}
  172. {{ _self.sort_header('project', 'app.report.col_project'|trans, sort, dir, '') }}
  173. {{ _self.sort_header('service', 'app.report.col_service'|trans, sort, dir, '') }}
  174. {{ _self.sort_header('user', 'app.report.col_user'|trans, sort, dir, '') }}
  175. {{ _self.sort_header('label', 'app.entry.label_label'|trans, sort, dir, '') }}
  176. {{ _self.sort_header('note', 'app.report.col_note'|trans, sort, dir, '') }}
  177. {{ _self.sort_header('duration', 'app.report.col_hours'|trans, sort, dir, totalDuration) }}
  178. {{ _self.sort_header('revenue', 'app.report.col_revenue'|trans, sort, dir, totalRevenue|number_format(2, ',', '.') ~ ' €') }}
  179. <div class="report-table__cell report-table__cell--actions"></div>
  180. </div>
  181. {# ── Einträge ──────────────────────────────────────────────────── #}
  182. {% for entry in entries %}
  183. {% set service = entry.service %}
  184. {% set billable = (service is null or service.billable) %}
  185. {% set hourlyRate = entry.project.hourlyRate ?? entry.project.client.hourlyRate ?? (service ? service.hourlyRate : null) %}
  186. {% set monthShort = monthsShort[entry.date|date('n') - 1] %}
  187. {% set canEdit = isAdmin or (entry.userId == currentUserId) %}
  188. <div class="report-table__row{% if entry.invoiced %} report-table__row--invoiced{% endif %}"
  189. data-entry-id="{{ entry.id }}"
  190. data-user-id="{{ entry.userId }}"
  191. data-project-id="{{ entry.project.id }}"
  192. data-service-id="{{ entry.service ? entry.service.id : '' }}"
  193. data-duration="{{ entry.duration }}"
  194. data-label="{{ entry.label|default('')|e('html_attr') }}"
  195. data-note="{{ entry.note|default('')|e('html_attr') }}"
  196. data-invoiced="{{ entry.invoiced ? 'true' : 'false' }}">
  197. <div class="report-table__cell report-table__cell--date">
  198. {{ entry.date|date('j') }}. {{ monthShort }} {{ entry.date|date('y') }}
  199. </div>
  200. <div class="report-table__cell report-table__cell--client">
  201. {{ entry.project.client.name }}
  202. </div>
  203. <div class="report-table__cell report-table__cell--project">
  204. {{ entry.project.name }}
  205. </div>
  206. <div class="report-table__cell report-table__cell--service">
  207. {{ service ? service.name : '' }}
  208. </div>
  209. <div class="report-table__cell report-table__cell--user">
  210. {{ userMap[entry.userId] ?? 'app.report.user_fallback'|trans({'%id%': entry.userId}) }}
  211. </div>
  212. <div class="report-table__cell report-table__cell--label">
  213. {% if entry.label %}<span class="report-table__label">{{ entry.label }}</span>{% endif %}
  214. </div>
  215. <div class="report-table__cell report-table__cell--note">
  216. {{ entry.note }}
  217. </div>
  218. <div class="report-table__cell report-table__cell--duration">
  219. {{ entry.durationFormatted }}
  220. </div>
  221. <div class="report-table__cell report-table__cell--revenue">
  222. {% if billable and hourlyRate is not null %}
  223. {{ (hourlyRate * entry.duration / 60)|number_format(2, ',', '.') }} €
  224. {% endif %}
  225. </div>
  226. <div class="report-table__cell report-table__cell--actions">
  227. {% if canEdit and not entry.invoiced %}
  228. <button class="report-action-btn report-action-btn--edit"
  229. data-action="edit"
  230. title="{{ 'app.entry.btn_edit'|trans }}">
  231. {% include '_atoms/icon-edit.html.twig' %}
  232. </button>
  233. <button class="report-action-btn report-action-btn--delete"
  234. data-action="delete"
  235. title="{{ 'app.entry.btn_delete'|trans }}">
  236. {% include '_atoms/icon-delete.html.twig' %}
  237. </button>
  238. {% endif %}
  239. {% if canEdit %}
  240. <button class="report-lock{% if entry.invoiced %} report-lock--invoiced{% endif %}"
  241. data-action="toggle-invoiced"
  242. title="{{ entry.invoiced ? 'app.report.btn_unlock'|trans : 'app.report.btn_lock'|trans }}">
  243. {% include '_atoms/icon-lock.html.twig' %}
  244. </button>
  245. {% endif %}
  246. </div>
  247. {# ── Inline-Edit-Formular ───────────────────────────────── #}
  248. {% if canEdit and not entry.invoiced %}
  249. <div class="report-row__edit" hidden>
  250. <div class="report-row__edit-grid">
  251. <label class="report-row__edit-label">{{ 'app.entry.label_duration'|trans }}</label>
  252. <div class="report-row__edit-field">
  253. <input type="text"
  254. class="input input--sm edit-duration"
  255. value="{{ entry.durationFormatted }}"
  256. autocomplete="off" />
  257. {% include '_atoms/duration-help.html.twig' %}
  258. </div>
  259. <label class="report-row__edit-label">{{ 'app.entry.label_project_service'|trans }}</label>
  260. <div class="report-row__edit-field report-row__edit-field--selects">
  261. <select class="select edit-project"></select>
  262. <select class="select edit-service"></select>
  263. </div>
  264. <label class="report-row__edit-label">{{ 'app.entry.label_label'|trans }}</label>
  265. <div class="report-row__edit-field">
  266. <input type="text" class="input input--sm edit-label"
  267. value="{{ entry.label|default('') }}"
  268. placeholder="{{ 'app.entry.placeholder_label'|trans }}" autocomplete="off" />
  269. </div>
  270. <label class="report-row__edit-label">{{ 'app.entry.label_note'|trans }}</label>
  271. <div class="report-row__edit-field">
  272. <textarea class="textarea edit-note" rows="2">{{ entry.note|default('') }}</textarea>
  273. </div>
  274. <div class="report-row__edit-actions">
  275. <button type="button" class="btn btn-primary" data-action="save">
  276. {{ 'app.entry.btn_save'|trans }}
  277. </button>
  278. <button type="button" class="btn btn-secondary" data-action="cancel">
  279. {{ 'app.entry.btn_cancel'|trans }}
  280. </button>
  281. </div>
  282. </div>
  283. </div>
  284. {% endif %}
  285. </div>
  286. {% else %}
  287. <div class="report-table__empty">{{ 'app.report.no_entries'|trans }}</div>
  288. {% endfor %}
  289. {# ── Pagination-Footer ─────────────────────────────────────────── #}
  290. <div class="report-pagination">
  291. <div class="report-pagination__limits">
  292. {{ 'app.report.show'|trans }}
  293. {% for l in validLimits %}
  294. {% if l == limit %}
  295. <strong>{{ l }}</strong>
  296. {% else %}
  297. <a href="{{ path('report_times', {limit: l}) }}">{{ l }}</a>
  298. {% endif %}
  299. {% endfor %}
  300. {{ 'app.report.of_total'|trans({'%count%': totalCount|number_format(0, ',', '.')}) }}
  301. </div>
  302. <span class="report-pagination__duration">{{ totalDuration }}</span>
  303. <span class="report-pagination__revenue">{{ totalRevenue|number_format(2, ',', '.') }} €</span>
  304. <span class="report-pagination__lock-spacer"></span>
  305. </div>
  306. </div>{# /.report-table #}
  307. </div>{# /.report-card #}
  308. </div>{# /.report-content #}
  309. </div>{# /.report-page #}
  310. {% if lexofficeInvoice %}
  311. <div class="modal-overlay" id="invoice-modal" hidden>
  312. <div class="modal-card invoice-modal">
  313. <div class="modal-card__header">
  314. <h2 class="modal-card__title">{{ 'app.report.invoice_modal_title'|trans }}</h2>
  315. <button type="button" class="modal-card__close" id="invoice-modal-close">
  316. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  317. <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
  318. </svg>
  319. </button>
  320. </div>
  321. <div class="modal-card__body">
  322. <div class="invoice-modal__group">
  323. <span class="invoice-modal__group-label">{{ 'app.report.invoice_group_label'|trans }}</span>
  324. <div class="invoice-modal__radios">
  325. <label class="invoice-modal__radio">
  326. <input type="radio" name="invoice-group" value="service" checked>
  327. <span>{{ 'app.report.invoice_group_service'|trans }}</span>
  328. </label>
  329. <label class="invoice-modal__radio">
  330. <input type="radio" name="invoice-group" value="project">
  331. <span>{{ 'app.report.invoice_group_project'|trans }}</span>
  332. </label>
  333. <label class="invoice-modal__radio">
  334. <input type="radio" name="invoice-group" value="label">
  335. <span>{{ 'app.report.invoice_group_by_label'|trans }}</span>
  336. </label>
  337. </div>
  338. </div>
  339. <div class="invoice-modal__preview" id="invoice-preview">
  340. <div class="invoice-modal__loading">{{ 'app.report.invoice_loading'|trans }}</div>
  341. </div>
  342. </div>
  343. <div class="modal-card__footer">
  344. <label class="invoice-modal__invoiced-check">
  345. <input type="checkbox" id="invoice-modal-mark-invoiced" checked>
  346. <span>{{ 'app.report.invoice_mark_invoiced'|trans }}</span>
  347. </label>
  348. <button type="button" class="btn btn-primary" id="invoice-modal-create" disabled>
  349. {{ 'app.report.invoice_btn_create'|trans }}
  350. </button>
  351. </div>
  352. </div>
  353. </div>
  354. {% endif %}
  355. {% endblock %}