added basic invoice rendering #97 #93 (#99)

This commit is contained in:
Kevin Papst
2018-01-21 22:50:46 +01:00
committed by GitHub
parent 9a161b8fd9
commit 4cbc5391c0
73 changed files with 2986 additions and 918 deletions

View File

@@ -8,10 +8,10 @@ Kimai v2 - the reloaded open source Time-Tracking application.
This is (or will be in the future, currently a lot of features are still missing) the reloaded version of the open source time-tracking application [Kimai](http://www.kimai.org).
It is based on the following PHP components:
It is based on a lot of great PHP components. Special thanks to:
- [Symfony Framework 4](https://github.com/symfony/symfony)
- [AdminThemeBundle](https://github.com/avanzu/AdminThemeBundle/) (based on [AdminLTE](https://github.com/almasaeed2010/AdminLTE/))
- [Doctrine](https://github.com/doctrine/)
- [AdminThemeBundle](https://github.com/avanzu/AdminThemeBundle/) (based on [AdminLTE](https://github.com/almasaeed2010/AdminLTE/))
## Requirements

8
composer.lock generated
View File

@@ -52,12 +52,12 @@
"source": {
"type": "git",
"url": "https://github.com/kevinpapst/AdminThemeBundle.git",
"reference": "d31084eff7888d76f7976700f9ac878f40c3bacd"
"reference": "d0d849ac59889ad6f4408fee84e23b09d7d65d36"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/kevinpapst/AdminThemeBundle/zipball/d31084eff7888d76f7976700f9ac878f40c3bacd",
"reference": "d31084eff7888d76f7976700f9ac878f40c3bacd",
"url": "https://api.github.com/repos/kevinpapst/AdminThemeBundle/zipball/d0d849ac59889ad6f4408fee84e23b09d7d65d36",
"reference": "d0d849ac59889ad6f4408fee84e23b09d7d65d36",
"shasum": ""
},
"require": {
@@ -111,7 +111,7 @@
"support": {
"source": "https://github.com/kevinpapst/AdminThemeBundle/tree/kevinpapst"
},
"time": "2018-01-17T12:31:37+00:00"
"time": "2018-01-21T19:07:18+00:00"
},
{
"name": "beberlei/DoctrineExtensions",

View File

@@ -1,13 +1,22 @@
kimai:
timesheet:
rounding: 15
invoice:
renderer:
default: 'App\Controller\InvoiceController::invoiceAction'
calculator:
default: 'App\Invoice\DefaultCalculator'
number_generator:
default: 'App\Invoice\DateNumberGenerator'
#random: 'App\Invoice\RandomNumberGenerator'
twig:
# form_themes:
# - "bootstrap_3_layout.html.twig"
# - "form/fields.html.twig"
globals:
kimai_context:
date_1: "d.m.Y" # used for display in timesheets
box_color: "green" # a color for ???
active_warning: 3 # display a warning color if the user has at least X active recordings
control_sidebar:
control_sidebar: # all tabs in the control sidebar
settings:
icon: gears
controller: 'App\Controller\SidebarController::settingsAction'

View File

@@ -12,6 +12,8 @@ services:
public: false # Allows optimizing the container by removing unused services; this also means
# fetching services directly from the container via $container->get() won't work.
# The best practice is to be explicit about your dependencies anyway.
bind:
$projectDirectory: "%kernel.project_dir%"
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
@@ -25,13 +27,14 @@ services:
resource: '../src/Controller'
tags: ['controller.service_arguments']
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
# ================================================================================
# APPLICATION CORE
# ================================================================================
App\Invoice\ServiceInvoice:
class: App\Invoice\ServiceInvoice
arguments: ['%kimai.invoice%', '@service_container']
# a route listener, that injects the locale through a URL directory
App\EventSubscriber\RedirectToLocaleSubscriber:
class: App\EventSubscriber\RedirectToLocaleSubscriber
@@ -44,34 +47,25 @@ services:
# ================================================================================
# service that prefixes every database table
app.tableprefix_subscriber:
App\Doctrine\TablePrefixSubscriber:
class: App\Doctrine\TablePrefixSubscriber
arguments: ["%env(resolve:DATABASE_PREFIX)%"]
tags:
- { name: doctrine.event_subscriber }
# updates timesheet records, to make sure they are calculated and can't be manipulated by the users
app.database_listener.timesheet:
App\Doctrine\TimesheetSubscriber:
class: App\Doctrine\TimesheetSubscriber
tags:
- { name: doctrine.event_listener, event: prePersist, lazy: true }
- { name: doctrine.event_listener, event: preUpdate, lazy: true }
# Uncomment the following lines to define a service for the Timesheet Doctrine repository.
# It's not mandatory to create these services, but if you use repositories a lot,
# these services can simplify your code:
# Uncomment the following lines to define a service for the Timesheet Doctrine repository
#
# app.post_repository:
# app.timesheet_repository:
# class: Doctrine\ORM\EntityRepository
# factory: ['@doctrine.orm.entity_manager', getRepository]
# arguments: [App\Entity\Timesheet]
#
# // traditional code inside a controller
# $entityManager = $this->getDoctrine()->getManager();
# $posts = $entityManager->getRepository('KimaiBundle:User')->findAll();
#
# // same code using repository services
# $posts = $this->get('app.user_repository')->findAll();
# ================================================================================
# FORMS
@@ -84,29 +78,6 @@ services:
tags:
- { name: form.type }
# ================================================================================
# CONSOLE COMMANDS
# ================================================================================
App\Command\RunCodeSnifferCommand:
arguments:
$projectDirectory: "%kernel.project_dir%"
App\Command\RunUnitTestsCommand:
class: App\Command\RunUnitTestsCommand
arguments:
$projectDirectory: "%kernel.project_dir%"
App\Command\RunIntegrationTestsCommand:
class: App\Command\RunIntegrationTestsCommand
arguments:
$projectDirectory: "%kernel.project_dir%"
App\Command\InstallCommand:
class: App\Command\InstallCommand
arguments:
$projectDirectory: "%kernel.project_dir%"
# ================================================================================
# THEME
# ================================================================================
@@ -128,9 +99,3 @@ services:
# class: "%avanzu_admin_theme.navbar_msg_listener.class%"
# tags:
# - { name: kernel.event_listener, event: theme.messages, method: onListMessages }
#
# avanzu_admin_theme.setup_menu_listener:
# class: "%avanzu_admin_theme.setup_menu_listener.class%"
# tags:
# - { name: kernel.event_listener, event: theme.sidebar_setup_menu, method: onSetupMenu }
# - { name: kernel.event_listener, event: theme.breadcrumb, method: onSetupMenu }

View File

@@ -1,3 +1,8 @@
.error-page {
margin-bottom: 50px;
}
/* ================================ NAVBAR ================================ */
.navbar-nav>li>a.ddt-large {
@@ -42,6 +47,7 @@ li.open .ticktac i.running{
.control-sidebar select {
color: #000;
}
/* ================================ TOOLBAR ================================ */
.data_table .box-header>.box-tools {
@@ -61,7 +67,7 @@ li.open .ticktac i.running{
display: inline;
}
.box-header .tools-left {
.tools-left {
margin-right: 10px;
}
@@ -84,6 +90,11 @@ footer.main-footer {
.login-page form label {
padding-left: 5px;
}
/* ================================ PRINT ================================ */
@media print{
.sf-toolbar, .control-sidebar {display: none !important;}
}
/* ================================ DATA-TABLES ================================ */

View File

@@ -1,5 +1,9 @@
$(document).ready(function () {
$('.toolbar form input').change(function (event) {
$('.toolbar form').submit();
});
$('.toolbar form select').change(function (event) {
switch (event.target.id) {
case 'customer':

View File

@@ -19,8 +19,6 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use App\Entity\Customer;
use App\Entity\Project;
use App\Form\ActivityEditForm;
use App\Form\Toolbar\ActivityToolbarForm;
use App\Repository\Query\ActivityQuery;
@@ -45,47 +43,6 @@ class ActivityController extends AbstractController
return $this->getDoctrine()->getRepository(Activity::class);
}
/**
* @param Request $request
* @return ActivityQuery
*/
protected function getQueryForRequest(Request $request)
{
$visibility = $request->get('visibility', ActivityQuery::SHOW_VISIBLE);
if (strlen($visibility) == 0 || (int)$visibility != $visibility) {
$visibility = ActivityQuery::SHOW_BOTH;
}
$pageSize = (int) $request->get('pageSize');
$customer = $request->get('customer');
$customer = !empty(trim($customer)) ? trim($customer) : null;
$project = $request->get('project');
$project = !empty(trim($project)) ? trim($project) : null;
if ($project !== null) {
$repo = $this->getDoctrine()->getRepository(Project::class);
$project = $repo->getById($project);
if ($project !== null) {
$customer = $project->getCustomer();
} else {
$customer = null;
}
} elseif ($customer !== null) {
$repo = $this->getDoctrine()->getRepository(Customer::class);
$customer = $repo->getById($customer);
}
$query = new ActivityQuery();
$query
->setPageSize($pageSize)
->setVisibility($visibility)
->setCustomer($customer)
->setProject($project)
->setExclusiveVisibility(true)
;
return $query ;
}
/**
* @Route("/", defaults={"page": 1}, name="admin_activity")
* @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_activity_paginated")
@@ -94,22 +51,33 @@ class ActivityController extends AbstractController
*/
public function indexAction($page, Request $request)
{
$query = $this->getQueryForRequest($request);
$query = new ActivityQuery();
$query->setExclusiveVisibility(true);
$query->setPage($page);
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var ActivityQuery $query */
$query = $form->getData();
}
/* @var $entries Pagerfanta */
$entries = $this->getRepository()->findByQuery($query);
return $this->render('admin/activity.html.twig', [
'entries' => $entries,
'query' => $query,
'toolbarForm' => $this->getToolbarForm($query)->createView(),
'toolbarForm' => $form->createView(),
]);
}
/**
* @Route("/create", name="admin_activity_create")
* @Method({"GET", "POST"})
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function createAction(Request $request)
{
@@ -120,6 +88,10 @@ class ActivityController extends AbstractController
* @Route("/{id}/edit", name="admin_activity_edit")
* @Method({"GET", "POST"})
* @Security("is_granted('edit', activity)")
*
* @param Activity $activity
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function editAction(Activity $activity, Request $request)
{
@@ -204,16 +176,12 @@ class ActivityController extends AbstractController
*/
protected function getToolbarForm(ActivityQuery $query)
{
return $this->createForm(
ActivityToolbarForm::class,
$query,
[
'action' => $this->generateUrl('admin_activity_paginated', [
'page' => $query->getPage(),
]),
'method' => 'GET',
]
);
return $this->createForm(ActivityToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_activity', [
'page' => $query->getPage(),
]),
'method' => 'GET',
]);
}
/**
@@ -228,13 +196,9 @@ class ActivityController extends AbstractController
$url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]);
}
return $this->createForm(
ActivityEditForm::class,
$activity,
[
'action' => $url,
'method' => 'POST'
]
);
return $this->createForm(ActivityEditForm::class, $activity, [
'action' => $url,
'method' => 'POST'
]);
}
}

View File

@@ -18,7 +18,6 @@ use App\Entity\Customer;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use App\Form\CustomerEditForm;
use App\Form\Toolbar\CustomerToolbarForm;
use App\Repository\Query\CustomerQuery;
@@ -43,44 +42,30 @@ class CustomerController extends AbstractController
return $this->getDoctrine()->getRepository(Customer::class);
}
/**
* @param Request $request
* @return CustomerQuery
*/
protected function getQueryForRequest(Request $request)
{
$visibility = $request->get('visibility', CustomerQuery::SHOW_VISIBLE);
if (strlen($visibility) == 0 || (int)$visibility != $visibility) {
$visibility = CustomerQuery::SHOW_BOTH;
}
$pageSize = (int) $request->get('pageSize');
$query = new CustomerQuery();
$query
->setPageSize($pageSize)
->setVisibility($visibility);
return $query ;
}
/**
* @Route("/", defaults={"page": 1}, name="admin_customer")
* @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_customer_paginated")
* @Method("GET")
* @Cache(smaxage="10")
*/
public function indexAction($page, Request $request)
{
$query = $this->getQueryForRequest($request);
$query = new CustomerQuery();
$query->setPage($page);
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var CustomerQuery $query */
$query = $form->getData();
}
/* @var $entries Pagerfanta */
$entries = $this->getRepository()->findByQuery($query);
return $this->render('admin/customer.html.twig', [
'entries' => $entries,
'query' => $query,
'toolbarForm' => $this->getToolbarForm($query)->createView(),
'toolbarForm' => $form->createView(),
]);
}

View File

@@ -44,36 +44,6 @@ class ProjectController extends AbstractController
return $this->getDoctrine()->getRepository(Project::class);
}
/**
* @param Request $request
* @return ProjectQuery
*/
protected function getQueryForRequest(Request $request)
{
$visibility = $request->get('visibility', ProjectQuery::SHOW_VISIBLE);
if (strlen($visibility) == 0 || (int)$visibility != $visibility) {
$visibility = ProjectQuery::SHOW_BOTH;
}
$pageSize = (int) $request->get('pageSize');
$customer = $request->get('customer');
$customer = !empty(trim($customer)) ? trim($customer) : null;
if ($customer !== null) {
$repo = $this->getDoctrine()->getRepository(Customer::class);
$customer = $repo->getById($customer);
}
$query = new ProjectQuery();
$query
->setPageSize($pageSize)
->setVisibility($visibility)
->setCustomer($customer)
->setExclusiveVisibility(true)
;
return $query ;
}
/**
* @Route("/", defaults={"page": 1}, name="admin_project")
* @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_project_paginated")
@@ -82,16 +52,24 @@ class ProjectController extends AbstractController
*/
public function indexAction($page, Request $request)
{
$query = $this->getQueryForRequest($request);
$query = new ProjectQuery();
$query->setExclusiveVisibility(true);
$query->setPage($page);
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var ProjectQuery $query */
$query = $form->getData();
}
/* @var $entries Pagerfanta */
$entries = $this->getDoctrine()->getRepository(Project::class)->findByQuery($query);
return $this->render('admin/project.html.twig', [
'entries' => $entries,
'query' => $query,
'toolbarForm' => $this->getToolbarForm($query)->createView(),
'toolbarForm' => $form->createView(),
]);
}

View File

@@ -12,6 +12,8 @@
namespace App\Controller\Admin;
use App\Controller\AbstractController;
use App\Form\Toolbar\TimesheetAdminToolbarForm;
use App\Repository\Query\TimesheetQuery;
use Pagerfanta\Pagerfanta;
use Symfony\Component\HttpFoundation\Request;
use App\Controller\TimesheetControllerTrait;
@@ -48,9 +50,16 @@ class TimesheetController extends AbstractController
*/
public function indexAction($page, Request $request)
{
$query = $this->getQueryForRequest($request);
$query = new TimesheetQuery();
$query->setPage($page);
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var TimesheetQuery $query */
$query = $form->getData();
}
/* @var $entries Pagerfanta */
$entries = $this->getRepository()->findByQuery($query);
@@ -58,7 +67,7 @@ class TimesheetController extends AbstractController
'entries' => $entries,
'page' => $page,
'query' => $query,
'toolbarForm' => $this->getToolbarForm($query, 'admin_timesheet')->createView(),
'toolbarForm' => $form->createView(),
]);
}
@@ -133,15 +142,11 @@ class TimesheetController extends AbstractController
*/
protected function getCreateForm(Timesheet $entry)
{
return $this->createForm(
TimesheetAdminForm::class,
$entry,
[
'action' => $this->generateUrl('admin_timesheet_create'),
'method' => 'POST',
'currency' => Customer::DEFAULT_CURRENCY,
]
);
return $this->createForm(TimesheetAdminForm::class, $entry, [
'action' => $this->generateUrl('admin_timesheet_create'),
'method' => 'POST',
'currency' => Customer::DEFAULT_CURRENCY,
]);
}
/**
@@ -151,17 +156,27 @@ class TimesheetController extends AbstractController
*/
protected function getEditForm(Timesheet $entry, $page)
{
return $this->createForm(
TimesheetAdminForm::class,
$entry,
[
'action' => $this->generateUrl('admin_timesheet_edit', [
'id' => $entry->getId(),
'page' => $page
]),
'method' => 'POST',
'currency' => $entry->getActivity()->getProject()->getCustomer()->getCurrency(),
]
);
return $this->createForm(TimesheetAdminForm::class, $entry, [
'action' => $this->generateUrl('admin_timesheet_edit', [
'id' => $entry->getId(),
'page' => $page
]),
'method' => 'POST',
'currency' => $entry->getActivity()->getProject()->getCustomer()->getCurrency(),
]);
}
/**
* @param TimesheetQuery $query
* @return \Symfony\Component\Form\FormInterface
*/
protected function getToolbarForm(TimesheetQuery $query)
{
return $this->createForm(TimesheetAdminToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_timesheet', [
'page' => $query->getPage(),
]),
'method' => 'GET',
]);
}
}

View File

@@ -20,7 +20,6 @@ use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use Symfony\Component\HttpFoundation\Request;
/**
@@ -35,29 +34,6 @@ use Symfony\Component\HttpFoundation\Request;
class UserController extends AbstractController
{
/**
* @param Request $request
* @return UserQuery
*/
protected function getQueryForRequest(Request $request)
{
$visibility = $request->get('visibility', UserQuery::SHOW_VISIBLE);
if (strlen($visibility) == 0 || (int)$visibility != $visibility) {
$visibility = UserQuery::SHOW_BOTH;
}
$pageSize = (int) $request->get('pageSize');
$userRole = $request->get('role');
$query = new UserQuery();
$query
->setPageSize($pageSize)
->setVisibility($visibility)
->setRole($userRole)
;
return $query;
}
/**
* @Route("/", defaults={"page": 1}, name="admin_user")
* @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_user_paginated")
@@ -65,16 +41,23 @@ class UserController extends AbstractController
*/
public function indexAction($page, Request $request)
{
$query = $this->getQueryForRequest($request);
$query = new UserQuery();
$query->setPage($page);
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var UserQuery $query */
$query = $form->getData();
}
/* @var $entries Pagerfanta */
$entries = $this->getDoctrine()->getRepository(User::class)->findByQuery($query);
return $this->render('admin/user.html.twig', [
'entries' => $entries,
'query' => $query,
'toolbarForm' => $this->getToolbarForm($query)->createView(),
'toolbarForm' => $form->createView(),
]);
}
@@ -120,16 +103,12 @@ class UserController extends AbstractController
*/
protected function getToolbarForm(UserQuery $query)
{
return $this->createForm(
UserToolbarForm::class,
$query,
[
'action' => $this->generateUrl('admin_user_paginated', [
'page' => $query->getPage(),
]),
'method' => 'GET',
]
);
return $this->createForm(UserToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_user_paginated', [
'page' => $query->getPage(),
]),
'method' => 'GET',
]);
}
/**
@@ -138,13 +117,9 @@ class UserController extends AbstractController
*/
private function createEditForm(User $user)
{
return $this->createForm(
UserCreateType::class,
$user,
[
'action' => $this->generateUrl('admin_user_create'),
'method' => 'POST'
]
);
return $this->createForm(UserCreateType::class, $user, [
'action' => $this->generateUrl('admin_user_create'),
'method' => 'POST'
]);
}
}

View File

@@ -0,0 +1,263 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller;
use App\Entity\InvoiceTemplate;
use App\Entity\Timesheet;
use App\Form\InvoiceTemplateForm;
use App\Form\Toolbar\InvoiceToolbarForm;
use App\Invoice\ServiceInvoice;
use App\Model\InvoiceModel;
use App\Repository\Query\BaseQuery;
use App\Repository\Query\InvoiceQuery;
use App\Repository\Query\TimesheetQuery;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
/**
* Controller used to manage invoices.
*
* @Route("/invoice")
* @Security("has_role('ROLE_TEAMLEAD')")
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class InvoiceController extends AbstractController
{
/**
* @var ServiceInvoice
*/
protected $service;
/**
* InvoiceController constructor.
* @param ServiceInvoice $service
*/
public function __construct(ServiceInvoice $service)
{
$this->service = $service;
}
/**
* @return InvoiceQuery
* @throws \Exception
*/
protected function getDefaultQuery()
{
$begin = new \DateTime('first day of this month');
$end = new \DateTime('last day of this month');
$query = new InvoiceQuery();
$query->setBegin($begin);
$query->setEnd($end);
$query->setUser($this->getUser());
$query->setState(InvoiceQuery::STATE_STOPPED);
return $query;
}
/**
* @return \App\Repository\InvoiceTemplateRepository
*/
protected function getRepository()
{
return $this->getDoctrine()->getRepository(InvoiceTemplate::class);
}
/**
* @Route("/", name="invoice")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function indexAction(Request $request)
{
if (!$this->getRepository()->hasTemplate()) {
return $this->redirectToRoute('admin_invoice_template_create');
}
$entries = [];
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var InvoiceQuery $query */
$query = $form->getData();
$query->setResultType(TimesheetQuery::RESULT_TYPE_QUERYBUILDER);
if ($query->getCustomer() !== null) {
$query->getBegin()->setTime(0, 0, 0);
$query->getEnd()->setTime(23, 59, 59);
$queryBuilder = $this->getDoctrine()->getRepository(Timesheet::class)->findByQuery($query);
$entries = $queryBuilder->getQuery()->getResult();
}
}
$model = new InvoiceModel();
$model->setQuery($query);
$model->setEntries($entries);
$model->setCustomer($query->getCustomer());
$action = null;
if ($query->getTemplate() !== null) {
$generator = $this->service->getNumberGeneratorByName($query->getTemplate()->getNumberGenerator());
if ($generator === null) {
throw new \Exception('Unknown number generator: ' . $query->getTemplate()->getNumberGenerator());
}
$calculator = $this->service->getCalculatorByName($query->getTemplate()->getCalculator());
if ($calculator === null) {
throw new \Exception('Unknown invoice calculator: ' . $query->getTemplate()->getCalculator());
}
$model->setTemplate($query->getTemplate());
$model->setCalculator($calculator);
$model->setNumberGenerator($generator);
$action = $this->service->getRendererActionByName($query->getTemplate()->getRenderer());
}
return $this->render('invoice/index.html.twig', [
'model' => $model,
'action' => $action,
'form' => $form->createView(),
]);
}
/**
* @Route("/template", defaults={"page": 1}, name="admin_invoice_template")
* @Route("/template/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_invoice_template_paginated")
* @Method({"GET", "POST"})
*
* TODO permission
*
* @param $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function listTemplateAction($page, Request $request)
{
$templates = $this->getRepository()->findByQuery(new BaseQuery());
return $this->render('invoice/templates.html.twig', [
'entries' => $templates,
'page' => $page,
]);
}
/**
* @Route("/{id}/edit", name="admin_invoice_template_edit")
* @Method({"GET", "POST"})
*
* TODO permission
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function editTemplateAction(InvoiceTemplate $template, Request $request)
{
return $this->renderTemplateForm($template, $request);
}
/**
* @Route("/create", name="admin_invoice_template_create")
* @Method({"GET", "POST"})
*
* TODO permission
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function createTemplateAction(Request $request)
{
if (!$this->getRepository()->hasTemplate()) {
$this->flashWarning('invoice.first_template');
}
return $this->renderTemplateForm(new InvoiceTemplate(), $request);
}
/**
* @param InvoiceTemplate $template
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
protected function renderTemplateForm(InvoiceTemplate $template, Request $request)
{
$editForm = $this->createEditForm($template);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($template);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
return $this->redirectToRoute('admin_invoice_template');
}
return $this->render('invoice/template_edit.html.twig', [
'template' => $template,
'form' => $editForm->createView()
]);
}
/**
* @param InvoiceModel $model
* @return \Symfony\Component\HttpFoundation\Response
*/
public function invoiceAction(InvoiceModel $model)
{
return $this->render('invoice/print.html.twig', [
'model' => $model,
]);
}
/**
* @param InvoiceQuery $query
* @return \Symfony\Component\Form\FormInterface
*/
protected function getToolbarForm(InvoiceQuery $query)
{
return $this->createForm(InvoiceToolbarForm::class, $query, [
'action' => $this->generateUrl('invoice', []),
'method' => 'GET',
]);
}
/**
* @param InvoiceTemplate $template
* @return \Symfony\Component\Form\FormInterface
*/
private function createEditForm(InvoiceTemplate $template)
{
if ($template->getId() === null) {
$url = $this->generateUrl('admin_invoice_template_create');
} else {
$url = $this->generateUrl('admin_invoice_template_edit', ['id' => $template->getId()]);
}
return $this->createForm(InvoiceTemplateForm::class, $template, [
'action' => $url,
'method' => 'POST'
]);
}
}

View File

@@ -11,7 +11,8 @@
namespace App\Controller;
use App\Controller\AbstractController;
use App\Form\Toolbar\TimesheetToolbarForm;
use App\Repository\Query\TimesheetQuery;
use Pagerfanta\Pagerfanta;
use App\Entity\Activity;
use App\Entity\Customer;
@@ -43,10 +44,18 @@ class TimesheetController extends AbstractController
*/
public function indexAction($page, Request $request)
{
$query = $this->getQueryForRequest($request);
$query->setUser($this->getUser());
$query = new TimesheetQuery();
$query->setPage($page);
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var TimesheetQuery $query */
$query = $form->getData();
}
$query->setUser($this->getUser());
/* @var $entries Pagerfanta */
$entries = $this->getRepository()->findByQuery($query);
@@ -54,7 +63,7 @@ class TimesheetController extends AbstractController
'entries' => $entries,
'page' => $page,
'query' => $query,
'toolbarForm' => $this->getToolbarForm($query)->createView(),
'toolbarForm' => $form->createView(),
]);
}
@@ -201,4 +210,18 @@ class TimesheetController extends AbstractController
]
);
}
/**
* @param TimesheetQuery $query
* @return \Symfony\Component\Form\FormInterface
*/
protected function getToolbarForm(TimesheetQuery $query)
{
return $this->createForm(TimesheetToolbarForm::class, $query, [
'action' => $this->generateUrl('timesheet', [
'page' => $query->getPage(),
]),
'method' => 'GET',
]);
}
}

View File

@@ -11,13 +11,8 @@
namespace App\Controller;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use Symfony\Component\HttpFoundation\Request;
use App\Form\Toolbar\TimesheetToolbarForm;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
/**
@@ -35,77 +30,6 @@ trait TimesheetControllerTrait
return $this->getDoctrine()->getRepository(Timesheet::class);
}
/**
* @param Request $request
* @return TimesheetQuery
*/
protected function getQueryForRequest(Request $request)
{
$activity = $request->get('activity');
$activity = !empty(trim($activity)) ? trim($activity) : null;
$project = $request->get('project');
$project = !empty(trim($project)) ? trim($project) : null;
$customer = $request->get('customer');
$customer = !empty(trim($customer)) ? trim($customer) : null;
$state = $request->get('state');
$state = !empty(trim($state)) ? trim($state) : null;
$pageSize = (int) $request->get('pageSize');
if ($activity !== null) {
$repo = $this->getDoctrine()->getRepository(Activity::class);
$activity = $repo->getById($activity);
if ($activity !== null) {
$project = $activity->getProject();
if ($project !== null) {
$customer = $project->getCustomer();
}
} else {
$customer = null;
$project = null;
}
} elseif ($project !== null) {
$repo = $this->getDoctrine()->getRepository(Project::class);
$project = $repo->getById($project);
if ($project !== null) {
$customer = $project->getCustomer();
} else {
$customer = null;
}
} elseif ($customer !== null) {
$repo = $this->getDoctrine()->getRepository(Customer::class);
$customer = $repo->getById($customer);
}
$query = new TimesheetQuery();
$query
->setActivity($activity)
->setProject($project)
->setCustomer($customer)
->setPageSize($pageSize)
->setState($state);
return $query ;
}
/**
* @param TimesheetQuery $query
* @param string $route
* @return mixed
*/
protected function getToolbarForm(TimesheetQuery $query, $route = 'timesheet')
{
return $this->createForm(
TimesheetToolbarForm::class,
$query,
[
'action' => $this->generateUrl($route, [
'page' => $query->getPage(),
]),
'method' => 'GET',
]
);
}
/**
* @param Timesheet $entry
* @param string $route

View File

@@ -12,17 +12,23 @@
namespace App\DataFixtures;
use App\Entity\User;
use App\Entity\UserPreference;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
/**
* Sample data to load in the database when running tests or for development
* Defines the sample data to load in the database when running the unit and
* functional tests or while development.
*
* Execute this command to load the data:
* $ php bin/console doctrine:fixtures:load
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class AppFixtures extends Fixture
{
use FixturesTrait;
const DEFAULT_PASSWORD = 'kitten';
@@ -31,6 +37,10 @@ class AppFixtures extends Fixture
*/
private $encoder;
/**
* AppFixtures constructor.
* @param UserPasswordEncoderInterface $encoder
*/
public function __construct(UserPasswordEncoderInterface $encoder)
{
$this->encoder = $encoder;
@@ -44,117 +54,53 @@ class AppFixtures extends Fixture
$this->loadUsers($manager);
}
/**
* @param ObjectManager $manager
*/
private function loadUsers(ObjectManager $manager)
{
$passwordEncoder = $this->encoder;
$claraCustomer = new User();
$claraCustomer
->setAlias('Clara Haynes')
->setTitle('CFO')
->setUsername('clara_customer')
->setEmail('clara_customer@example.com')
->setRoles(['ROLE_CUSTOMER'])
->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y')
->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD))
;
$manager->persist($claraCustomer);
foreach ($this->getUserDefinition() as $userData) {
$user = new User();
$user
->setAlias($userData[0])
->setTitle($userData[1])
->setUsername($userData[2])
->setEmail($userData[3])
->setRoles([$userData[4]])
->setAvatar($userData[5])
->setActive($userData[6])
->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD))
;
$johnUser = new User();
$johnUser
->setAlias('John Doe')
->setTitle('Developer')
->setUsername('john_user')
->setEmail('john_user@example.com')
->setRoles(['ROLE_USER'])
->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y')
->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD))
;
$manager->persist($johnUser);
$preference = new UserPreference();
$preference->setName(UserPreference::HOURLY_RATE);
$preference->setValue(rand(0, 100));
$preference->setUser($user);
$user->setPreferences([$preference]);
$deactiveUser = new User();
$deactiveUser
->setAlias('Chris Deactive')
->setTitle('Developer (left company)')
->setUsername('chris_user')
->setEmail('chris_user@example.com')
->setRoles(['ROLE_USER'])
->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y')
->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD))
// inactive for testing user login and UI
->setActive(false)
;
$manager->persist($deactiveUser);
$tonyTeamlead = new User();
$tonyTeamlead
->setAlias('Tony Maier')
->setTitle('Head of Development')
->setUsername('tony_teamlead')
->setEmail('tony_teamlead@example.com')
->setRoles(['ROLE_TEAMLEAD'])
->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg')
->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD))
;
$manager->persist($tonyTeamlead);
$annaAdmin = new User();
$annaAdmin
->setAlias('Anna Smith')
->setTitle('Administrator')
->setUsername('anna_admin')
->setEmail('anna_admin@example.com')
->setRoles(['ROLE_ADMIN'])
// no avatar to test default image!
->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD))
;
$manager->persist($annaAdmin);
$susanSuper = new User();
$susanSuper
// no alias to test the username macros
->setTitle('Super Administrator')
->setUsername('susan_super')
->setEmail('susan_super@example.com')
->setRoles(['ROLE_SUPER_ADMIN'])
->setAvatar('/bundles/avanzuadmintheme/img/avatar.png')
->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD))
;
$manager->persist($susanSuper);
$manager->persist($user);
}
$manager->flush();
}
protected function getPhrases()
/**
* @return []
*/
protected function getUserDefinition()
{
return [
'Lorem ipsum dolor sit amet consectetur adipiscing elit',
'Pellentesque vitae velit ex',
'Mauris dapibus risus quis suscipit vulputate',
'Eros diam egestas libero eu vulputate risus',
'In hac habitasse platea dictumst',
'Morbi tempus commodo mattis',
'Ut suscipit posuere justo at vulputate',
'Ut eleifend mauris et risus ultrices egestas',
'Aliquam sodales odio id eleifend tristique',
'Urna nisl sollicitudin id varius orci quam id turpis',
'Nulla porta lobortis ligula vel egestas',
'Curabitur aliquam euismod dolor non ornare',
'Sed varius a risus eget aliquam',
'Nunc viverra elit ac laoreet suscipit',
'Pellentesque et sapien pulvinar consectetur',
['Clara Haynes', 'CFO', 'clara_customer', 'clara_customer@example.com', 'ROLE_CUSTOMER', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y', true],
['John Doe', 'Developer', 'john_user', 'john_user@example.com', 'ROLE_USER', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', true],
// inactive user to test login
['Chris Deactive', 'Developer (left company)', 'chris_user', 'chris_user@example.com', 'ROLE_USER', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', false],
['Tony Maier', 'Head of Development', 'tony_teamlead', 'tony_teamlead@example.com', 'ROLE_TEAMLEAD', 'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg', true],
// no avatar to test default image macro
['Anna Smith', 'Administrator', 'anna_admin', 'anna_admin@example.com', 'ROLE_ADMIN', null, true],
// no alias to test twig username macro
[null, 'Super Administrator', 'susan_super', 'susan_super@example.com', 'ROLE_SUPER_ADMIN', '/bundles/avanzuadmintheme/img/avatar.png', true]
];
}
protected function getRandomPhrase()
{
return $this->getRandomPostTitle();
}
private function getRandomPostTitle()
{
$titles = $this->getPhrases();
return $titles[array_rand($titles)];
}
}

View File

@@ -0,0 +1,53 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\DataFixtures;
/**
* Defines reusable sample data.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
trait FixturesTrait
{
/**
* @return string[]
*/
protected function getPhrases()
{
return [
'Lorem ipsum dolor sit amet consectetur adipiscing elit',
'Pellentesque vitae velit ex',
'Mauris dapibus risus quis suscipit vulputate',
'Eros diam egestas libero eu vulputate risus',
'In hac habitasse platea dictumst',
'Morbi tempus commodo mattis',
'Ut suscipit posuere justo at vulputate',
'Ut eleifend mauris et risus ultrices egestas',
'Aliquam sodales odio id eleifend tristique',
'Urna nisl sollicitudin id varius orci quam id turpis',
'Nulla porta lobortis ligula vel egestas',
'Curabitur aliquam euismod dolor non ornare',
'Sed varius a risus eget aliquam',
'Nunc viverra elit ac laoreet suscipit',
'Pellentesque et sapien pulvinar consectetur',
];
}
/**
* @return string
*/
protected function getRandomPhrase()
{
$phrases = $this->getPhrases();
return $phrases[array_rand($phrases)];
}
}

View File

@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\DataFixtures;
use App\Entity\InvoiceTemplate;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
/**
* Defines the sample data to load in the database when running the unit and
* functional tests or while development.
*
* Execute this command to load the data:
* $ php bin/console doctrine:fixtures:load
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class InvoiceFixtures extends Fixture
{
/**
* {@inheritdoc}
*/
public function load(ObjectManager $manager)
{
$this->loadInvoiceTemplates($manager);
}
/**
* @param ObjectManager $manager
*/
private function loadInvoiceTemplates(ObjectManager $manager)
{
$template = new InvoiceTemplate();
$template
->setName('Invoice')
->setTitle('Your company name')
->setCompany('Kimai, Inc.')
->setVat(19)
->setDueDays(14)
->setPaymentTerms(
'I would like to thank you for your confidence and will gladly be there for you in the future.
Please transfer the total amount within 14 days to the given account and use the invoice number as reference.'
)
->setAddress(
'795 Folsom Ave, Suite 600
San Francisco, CA 94107
Phone: (804) 123-5432
Email: info@almasaeedstudio.com'
)
;
$manager->persist($template);
$manager->flush();
}
}

View File

@@ -16,18 +16,22 @@ use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
/**
* Defines the sample data to load in the database when running the unit and
* functional tests. Execute this command to load the data:
* functional tests or while development.
*
* $ php bin/console doctrine:fixtures:load
* Execute this command to load the data:
* $ php bin/console doctrine:fixtures:load
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class TimesheetFixtures extends AppFixtures
class TimesheetFixtures extends Fixture
{
use FixturesTrait;
const AMOUNT_TIMESHEET = 5000; // timesheet entries total
const RATE_MIN = 10; // minimum rate for one hour
const RATE_MAX = 80; // maximum rate for one hour
@@ -185,11 +189,11 @@ class TimesheetFixtures extends AppFixtures
$entry = new Customer();
$entry
->setCurrency($this->getRandomCurrency())
->setVat(rand(0, 30))
->setName($customerName . ($visible ? '' : '.'))
->setAddress($this->getRandomLocation())
->setComment($this->getRandomPhrase())
->setVisible($visible)
->setNumber('C0815-42-' . $i)
->setCountry('DE') // TODO randomize country ?
->setTimezone($allTimezones[rand(1, $amountTimezone)]);

View File

@@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\DependencyInjection;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\Config\Definition\Exception\InvalidDefinitionException;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
/**
* This is the class that loads and manages your bundle configuration
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class AppExtension extends Extension
{
/**
* {@inheritdoc}
*/
public function load(array $configs, ContainerBuilder $container)
{
$configuration = new Configuration();
try {
$config = $this->processConfiguration($configuration, $configs);
} catch (InvalidConfigurationException $e) {
trigger_error('Found invalid "kimai" configuration: ' . $e->getMessage());
$config = [];
}
$this->createInvoiceParameter($config, $container);
}
/**
* @param array $config
* @param ContainerBuilder $container
*/
private function createInvoiceParameter(array $config, ContainerBuilder $container)
{
$keys = ['renderer', 'calculator', 'number_generator'];
foreach ($keys as $key) {
if (!isset($config['invoice'][$key]) || 0 === count($config['invoice'][$key])) {
throw new InvalidDefinitionException('Missing invoice configuration: kimai.invoice.' . $key);
}
$container->setParameter('kimai.invoice.' . $key, $config['invoice'][$key]);
}
$container->setParameter('kimai.invoice', $config['invoice']);
}
/**
* {@inheritdoc}
*/
public function getAlias()
{
return 'kimai';
}
}

View File

@@ -29,16 +29,46 @@ class Configuration implements ConfigurationInterface
$treeBuilder = new TreeBuilder();
$rootNode = $treeBuilder->root('kimai');
// FIXME add kimai config
$rootNode
->children()
->arrayNode('timesheet')
->children()
->integerNode('round_record')->end()
->children()
->integerNode('rounding')->end()
->end()
->end()
->arrayNode('invoice')
->children()
->arrayNode('renderer')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue(array(
'default' => 'App\Controller\InvoiceController::invoiceAction',
))
->end()
->arrayNode('calculator')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue(array(
'default' => 'App\Invoice\DefaultCalculator',
))
->end()
->arrayNode('number_generator')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue(array(
'default' => 'App\Invoice\DateNumberGenerator',
))
->end()
->end()
->end()
->end()
->end()
;
->end();
return $treeBuilder;
}

View File

@@ -60,23 +60,25 @@ class TimesheetSubscriber implements EventSubscriber
$entity = $args->getObject();
if ($entity instanceof Timesheet) {
$duration = 0;
if ($entity->getEnd() !== null) {
$duration = $entity->getEnd()->getTimestamp() - $entity->getBegin()->getTimestamp();
$entity->setDuration($duration);
// TODO allow to set hourly rate on activity, project and customer and prefer these
$hourlyRate = 0;
foreach ($entity->getUser()->getPreferences() as $preference) {
if ($preference->getName() == UserPreference::HOURLY_RATE) {
$hourlyRate = (int) $preference->getValue();
}
}
$rate = $hourlyRate * ($duration / 3600);
$rate = $this->calculateRate($entity);
$entity->setRate($rate);
}
}
}
/**
* @param Timesheet $entity
* @return float
*/
protected function calculateRate(Timesheet $entity)
{
$hourlyRate = (float) $entity->getUser()->getPreferenceValue(UserPreference::HOURLY_RATE, 0);
return (float) $hourlyRate * ($entity->getDuration() / 3600);
}
}

View File

@@ -44,6 +44,14 @@ class Customer
*/
private $name;
/**
* @var string
*
* @ORM\Column(name="number", type="string", length=50, nullable=false)
* @Assert\NotBlank()
*/
private $number;
/**
* @var string
*
@@ -73,15 +81,6 @@ class Customer
*/
private $company;
/**
* @var integer
*
* @ORM\Column(name="vat", type="integer", length=2, nullable=true)
* @Assert\Range(min = 0, max = 99)
*
*/
private $vat;
/**
* @var string
*
@@ -92,7 +91,7 @@ class Customer
/**
* @var string
*
* @ORM\Column(name="street", type="text", length=65535, nullable=true)
* @ORM\Column(name="address", type="text", length=65535, nullable=true)
*/
private $address;
@@ -167,13 +166,11 @@ class Customer
* Set name
*
* @param string $name
*
* @return Customer
*/
public function setName($name)
{
$this->name = $name;
return $this;
}
@@ -182,22 +179,38 @@ class Customer
*
* @return string
*/
public function getName()
public function getName(): ?string
{
return $this->name;
}
/**
* @param string $number
* @return Customer
*/
public function setNumber(string $number)
{
$this->number = $number;
return $this;
}
/**
* @return string
*/
public function getNumber(): ?string
{
return $this->number;
}
/**
* Set comment
*
* @param string $comment
*
* @return Customer
*/
public function setComment($comment)
{
$this->comment = $comment;
return $this;
}
@@ -215,13 +228,11 @@ class Customer
* Set visible
*
* @param boolean $visible
*
* @return Customer
*/
public function setVisible($visible)
{
$this->visible = $visible;
return $this;
}
@@ -239,13 +250,11 @@ class Customer
* Set company
*
* @param string $company
*
* @return Customer
*/
public function setCompany($company)
{
$this->company = $company;
return $this;
}
@@ -259,41 +268,15 @@ class Customer
return $this->company;
}
/**
* Set vat
*
* @param integer $vat
*
* @return Customer
*/
public function setVat($vat)
{
$this->vat = $vat;
return $this;
}
/**
* Get vat
*
* @return integer
*/
public function getVat()
{
return $this->vat;
}
/**
* Set contact
*
* @param string $contact
*
* @return Customer
*/
public function setContact($contact)
{
$this->contact = $contact;
return $this;
}
@@ -307,6 +290,16 @@ class Customer
return $this->contact;
}
/**
* @param string $address
* @return Customer
*/
public function setAddress($address)
{
$this->address = $address;
return $this;
}
/**
* @return string
*/
@@ -315,29 +308,15 @@ class Customer
return $this->address;
}
/**
* @param string $address
*
* @return Customer
*/
public function setAddress($address)
{
$this->address = $address;
return $this;
}
/**
* Set country
*
* @param string $country
*
* @return Customer
*/
public function setCountry($country)
{
$this->country = $country;
return $this;
}
@@ -351,6 +330,16 @@ class Customer
return $this->country;
}
/**
* @param string $currency
* @return Customer
*/
public function setCurrency($currency)
{
$this->currency = $currency;
return $this;
}
/**
* @return string
*/
@@ -359,28 +348,15 @@ class Customer
return $this->currency;
}
/**
* @param string $currency
* @return $this
*/
public function setCurrency($currency)
{
$this->currency = $currency;
return $this;
}
/**
* Set phone
*
* @param string $phone
*
* @return Customer
*/
public function setPhone($phone)
{
$this->phone = $phone;
return $this;
}
@@ -398,13 +374,11 @@ class Customer
* Set fax
*
* @param string $fax
*
* @return Customer
*/
public function setFax($fax)
{
$this->fax = $fax;
return $this;
}
@@ -422,13 +396,11 @@ class Customer
* Set mobile
*
* @param string $mobile
*
* @return Customer
*/
public function setMobile($mobile)
{
$this->mobile = $mobile;
return $this;
}
@@ -446,13 +418,11 @@ class Customer
* Set mail
*
* @param string $mail
*
* @return Customer
*/
public function setMail($mail)
{
$this->mail = $mail;
return $this;
}
@@ -470,13 +440,11 @@ class Customer
* Set homepage
*
* @param string $homepage
*
* @return Customer
*/
public function setHomepage($homepage)
{
$this->homepage = $homepage;
return $this;
}
@@ -494,13 +462,11 @@ class Customer
* Set timezone
*
* @param string $timezone
*
* @return Customer
*/
public function setTimezone($timezone)
{
$this->timezone = $timezone;
return $this;
}
@@ -516,12 +482,11 @@ class Customer
/**
* @param Project[] $projects
* @return $this
* @return Customer
*/
public function setProjects($projects)
{
$this->projects = $projects;
return $this;
}

View File

@@ -0,0 +1,317 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* InvoiceTemplate
*
* @ORM\Entity(repositoryClass="App\Repository\InvoiceTemplateRepository")
* @ORM\Table(
* name="invoice_templates",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"name"})
* }
* )
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class InvoiceTemplate
{
/**
* @var integer
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=255, nullable=false)
* @Assert\NotBlank()
*/
private $name;
/**
* @var string
*
* @ORM\Column(name="title", type="string", length=255, nullable=false)
* @Assert\NotBlank()
*/
private $title;
/**
* @var string
*
* @ORM\Column(name="company", type="string", length=255, nullable=false)
* @Assert\NotBlank()
*/
private $company;
/**
* @var string
*
* @ORM\Column(name="address", type="text", length=65535, nullable=true)
*/
private $address;
/**
* @var int
*
* @ORM\Column(name="due_days", type="integer", length=3, nullable=false)
* @Assert\Range(min = 0, max = 999)
*/
private $dueDays = 30;
/**
* @var float
*
* @ORM\Column(name="vat", type="integer", length=2, nullable=true)
* @Assert\Range(min = 0, max = 99)
*/
private $vat = 0.00;
/**
* @var string
*
* @ORM\Column(name="calculator", type="string", length=20, nullable=false)
* @Assert\NotBlank()
*/
private $calculator = 'default';
/**
* @var string
*
* @ORM\Column(name="number_generator", type="string", length=20, nullable=false)
* @Assert\NotBlank()
*/
private $numberGenerator = 'default';
/**
* @var string
*
* @ORM\Column(name="renderer", type="string", length=20, nullable=false)
* @Assert\NotBlank()
*/
private $renderer = 'default';
/**
* @var string
*
* @ORM\Column(name="payment_terms", type="text", length=65535, nullable=true)
*/
private $paymentTerms;
/**
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* Set name
*
* @param string $name
* @return $this
*/
public function setName($name)
{
$this->name = $name;
return $this;
}
/**
* Get name
*
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* @return string
*/
public function getTitle(): ?string
{
return $this->title;
}
/**
* @param string $title
* @return InvoiceTemplate
*/
public function setTitle(string $title)
{
$this->title = $title;
return $this;
}
/**
* @return string
*/
public function getAddress(): ?string
{
return $this->address;
}
/**
* @param string $address
* @return InvoiceTemplate
*/
public function setAddress(string $address)
{
$this->address = $address;
return $this;
}
/**
* @return string
*/
public function getNumberGenerator(): ?string
{
return $this->numberGenerator;
}
/**
* @param string $numberGenerator
* @return InvoiceTemplate
*/
public function setNumberGenerator(string $numberGenerator)
{
$this->numberGenerator = $numberGenerator;
return $this;
}
/**
* @return int
*/
public function getDueDays(): ?int
{
return $this->dueDays;
}
/**
* @param int $dueDays
* @return InvoiceTemplate
*/
public function setDueDays(int $dueDays)
{
$this->dueDays = $dueDays;
return $this;
}
/**
* @return float
*/
public function getVat(): ?float
{
return $this->vat;
}
/**
* @param float $vat
* @return InvoiceTemplate
*/
public function setVat(float $vat)
{
$this->vat = $vat;
return $this;
}
/**
* @return string
*/
public function getCompany(): ?string
{
return $this->company;
}
/**
* @param string $company
* @return InvoiceTemplate
*/
public function setCompany(string $company)
{
$this->company = $company;
return $this;
}
/**
* @return string
*/
public function getRenderer(): string
{
return $this->renderer;
}
/**
* @param string $renderer
* @return InvoiceTemplate
*/
public function setRenderer(string $renderer)
{
$this->renderer = $renderer;
return $this;
}
/**
* @return string
*/
public function getCalculator(): string
{
return $this->calculator;
}
/**
* @param string $calculator
* @return InvoiceTemplate
*/
public function setCalculator(string $calculator)
{
$this->calculator = $calculator;
return $this;
}
/**
* @return string
*/
public function getPaymentTerms(): ?string
{
return $this->paymentTerms;
}
/**
* @param string $paymentTerms
* @return InvoiceTemplate
*/
public function setPaymentTerms(string $paymentTerms)
{
$this->paymentTerms = $paymentTerms;
return $this;
}
/**
* @return string
*/
public function __toString()
{
return $this->getName();
}
}

View File

@@ -90,7 +90,7 @@ class Timesheet
private $description;
/**
* @var string
* @var float
*
* @ORM\Column(name="rate", type="decimal", precision=10, scale=2, nullable=false)
*/
@@ -150,7 +150,6 @@ class Timesheet
* Set duration
*
* @param integer $duration
*
* @return Timesheet
*/
public function setDuration($duration)
@@ -182,7 +181,6 @@ class Timesheet
* Set user
*
* @param User $user
*
* @return Timesheet
*/
public function setUser(User $user)
@@ -205,7 +203,6 @@ class Timesheet
* Set activity
*
* @param Activity $activity
*
* @return Timesheet
*/
public function setActivity($activity)
@@ -228,7 +225,6 @@ class Timesheet
* Set description
*
* @param string $description
*
* @return Timesheet
*/
public function setDescription($description)
@@ -250,8 +246,7 @@ class Timesheet
/**
* Set rate
*
* @param string $rate
*
* @param float $rate
* @return Timesheet
*/
public function setRate($rate)
@@ -263,7 +258,7 @@ class Timesheet
/**
* Get rate
*
* @return string
* @return float
*/
public function getRate()
{

View File

@@ -369,6 +369,35 @@ class User implements UserInterface, AdvancedUserInterface
return $this;
}
/**
* @param string $name
* @return UserPreference|null
*/
public function getPreference(string $name)
{
foreach ($this->preferences as $preference) {
if ($preference->getName() == $name) {
return $preference;
}
}
return null;
}
/**
* @param $name
* @param null $default
* @return bool|int|null|string
*/
public function getPreferenceValue($name, $default = null)
{
$preference = $this->getPreference($name);
if ($preference === null) {
return $default;
}
return $preference->getValue();
}
/**
* @param UserPreference $preference
* @return User

View File

@@ -59,6 +59,7 @@ class MenuSubscriber implements EventSubscriberInterface
$isLoggedIn = $auth->isGranted('IS_AUTHENTICATED_REMEMBERED');
$isUser = $isLoggedIn && $auth->isGranted('ROLE_USER');
$isTeamlead = $isLoggedIn && $auth->isGranted('ROLE_USER');
if (!$isLoggedIn || !$isUser) {
return;
@@ -68,6 +69,14 @@ class MenuSubscriber implements EventSubscriberInterface
$menu->addItem(
new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], 'fa fa-clock-o')
);
if (!$isTeamlead) {
return;
}
$menu->addItem(
new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], 'fa fa-print')
);
}
/**

View File

@@ -16,7 +16,6 @@ use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PercentType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -40,84 +39,57 @@ class CustomerEditForm extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
// string - length 255
->add('name', TextType::class, [
'label' => 'label.name',
])
// text
->add('number', TextType::class, [
'label' => 'label.customer_number',
])
->add('comment', TextareaType::class, [
'label' => 'label.comment',
'required' => false,
])
// do not allow project selection:
// 1. it is a bad UX
// 2. what should happen if they are detached?
/*
->add('projects', EntityType::class, [
'label' => 'label.project',
'class' => 'Kimai:Project',
'multiple' => true,
'expanded' => true
])
*/
// boolean
->add('visible', VisibilityType::class, [
'label' => 'label.visible',
])
// string - length 255
->add('company', TextType::class, [
'label' => 'label.company',
'required' => false,
])
// string - length 255
->add('vat', PercentType::class, [
'label' => 'label.vat',
'type' => 'integer',
])
// string - length 255
->add('contact', TextType::class, [
'label' => 'label.contact',
'required' => false,
])
// string - length 255
->add('address', TextareaType::class, [
'label' => 'label.address',
'required' => false,
])
// string - length 2
->add('country', CountryType::class, [
'label' => 'label.country',
])
// string - length 3
->add('currency', CurrencyType::class, [
'label' => 'label.currency',
])
// string - length 255
->add('phone', TelType::class, [
'label' => 'label.phone',
'required' => false,
])
// string - length 255
->add('fax', TelType::class, [
'label' => 'label.fax',
'required' => false,
])
// string - length 255
->add('mobile', TelType::class, [
'label' => 'label.mobile',
'required' => false,
])
// string - length 255
->add('mail', EmailType::class, [
'label' => 'label.email',
'required' => false,
])
// string - length 255
->add('homepage', UrlType::class, [
'label' => 'label.homepage',
'required' => false,
])
// string - length 255
->add('timezone', TimezoneType::class, [
'label' => 'label.timezone',
])

View File

@@ -0,0 +1,81 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\InvoiceTemplate;
use App\Form\Type\InvoiceCalculatorType;
use App\Form\Type\InvoiceNumberGeneratorType;
use App\Form\Type\InvoiceRendererType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PercentType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the form used to manipulate invoice templates.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class InvoiceTemplateForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name', TextType::class, [
'label' => 'label.name',
])
->add('title', TextType::class, [
'label' => 'label.title',
])
->add('company', TextType::class, [
'label' => 'label.company',
])
->add('address', TextareaType::class, [
'label' => 'label.address',
'required' => false,
])
->add('paymentTerms', TextareaType::class, [
'label' => 'label.payment_terms',
'required' => false,
])
->add('dueDays', TextType::class, [
'label' => 'label.due_days',
])
->add('vat', PercentType::class, [
'label' => 'label.vat',
'type' => 'integer',
])
->add('renderer', InvoiceRendererType::class, [])
->add('calculator', InvoiceCalculatorType::class, [])
->add('numberGenerator', InvoiceNumberGeneratorType::class, [])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => InvoiceTemplate::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_invoice_template',
]);
}
}

View File

@@ -11,15 +11,33 @@
namespace App\Form\Toolbar;
use App\Form\Type\ActivityType;
use App\Form\Type\CustomerType;
use App\Form\Type\PageSizeType;
use App\Form\Type\ProjectType;
use App\Form\Type\UserRoleType;
use App\Form\Type\UserType;
use App\Form\Type\VisibilityType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\CustomerQuery;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
/**
* Defines the base form used for all toolbars.
*
* Extend this class and stack the elements defined here, they are coupled to each other and with the toolbar.js.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
abstract class AbstractToolbarForm extends AbstractType
{
/**
* Dirty hack to enable easy handling of GET form in controller and javascript.
*Cleans up the name of all form elents (and unfortunately of the form itself).
@@ -30,4 +48,135 @@ abstract class AbstractToolbarForm extends AbstractType
{
return '';
}
/**
* @param FormBuilderInterface $builder
*/
protected function addUserChoice(FormBuilderInterface $builder)
{
$builder->add('user', UserType::class, [
'label' => 'label.user',
'required' => false,
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addCustomerChoice(FormBuilderInterface $builder)
{
$builder->add('customer', CustomerType::class, [
'required' => false,
'query_builder' => function (CustomerRepository $repo) {
$query = new CustomerQuery();
$query->setVisibility(CustomerQuery::SHOW_BOTH); // this field is the reason for the query here
$query->setResultType(CustomerQuery::RESULT_TYPE_QUERYBUILDER);
return $repo->findByQuery($query);
},
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addVisibilityChoice(FormBuilderInterface $builder)
{
$builder->add('visibility', VisibilityType::class, []);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addPageSizeChoice(FormBuilderInterface $builder)
{
$builder->add('pageSize', PageSizeType::class, [
'required' => false,
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addUserRoleChoice(FormBuilderInterface $builder)
{
$builder->add('role', UserRoleType::class, [
'required' => false,
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addStartDateChoice(FormBuilderInterface $builder)
{
$builder->add('begin', DateType::class, [
'label' => 'label.begin',
'widget' => 'single_text',
'required' => false,
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addEndDateChoice(FormBuilderInterface $builder)
{
$builder->add('end', DateType::class, [
'label' => 'label.end',
'widget' => 'single_text',
'required' => false,
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addProjectChoice(FormBuilderInterface $builder)
{
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) {
$data = $event->getData();
if (!isset($data['customer']) || empty($data['customer'])) {
return;
}
$event->getForm()->add('project', ProjectType::class, array(
'group_by' => null,
'required' => false,
'query_builder' => function (ProjectRepository $repo) use ($data) {
$qb = $repo->builderForEntityType();
$qb->where('p.customer = :customer')->setParameter('customer', $data['customer']);
return $qb;
},
));
}
);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addActivityChoice(FormBuilderInterface $builder)
{
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) {
$data = $event->getData();
if (!isset($data['project']) || empty($data['project'])) {
return;
}
$event->getForm()->add('activity', ActivityType::class, array(
'group_by' => null,
'required' => false,
'query_builder' => function (ActivityRepository $repo) use ($data) {
$qb = $repo->builderForEntityType();
$qb->where('a.project = :project')->setParameter('project', $data['project']);
return $qb;
},
));
}
);
}
}

View File

@@ -13,7 +13,6 @@ namespace App\Form\Toolbar;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use App\Form\Type\ProjectType;
use App\Repository\Query\ActivityQuery;
/**
@@ -21,7 +20,7 @@ use App\Repository\Query\ActivityQuery;
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class ActivityToolbarForm extends ProjectToolbarForm
class ActivityToolbarForm extends AbstractToolbarForm
{
/**
@@ -29,25 +28,10 @@ class ActivityToolbarForm extends ProjectToolbarForm
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
/** @var ActivityQuery $query */
$query = $options['data'];
if ($query->getCustomer() === null) {
return;
}
$choices = [];
foreach ($query->getCustomer()->getProjects() as $project) {
$choices[] = $project;
}
$builder
->add('project', ProjectType::class, [
'required' => false,
'choices' => $choices,
]);
$this->addPageSizeChoice($builder);
$this->addVisibilityChoice($builder);
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
}
/**

View File

@@ -11,7 +11,7 @@
namespace App\Form\Toolbar;
use App\Form\Toolbar\VisibilityToolbarForm;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use App\Repository\Query\CustomerQuery;
@@ -20,9 +20,18 @@ use App\Repository\Query\CustomerQuery;
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class CustomerToolbarForm extends VisibilityToolbarForm
class CustomerToolbarForm extends AbstractToolbarForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->addPageSizeChoice($builder);
$this->addVisibilityChoice($builder);
}
/**
* {@inheritdoc}
*/

View File

@@ -0,0 +1,58 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Toolbar;
use App\Form\Type\InvoiceTemplateType;
use App\Repository\Query\InvoiceQuery;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the form used for filtering timesheet entries for invoices.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class InvoiceToolbarForm extends AbstractToolbarForm
{
/**
* @inheritdoc
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->addTemplateChoice($builder);
$this->addUserChoice($builder);
$this->addStartDateChoice($builder);
$this->addEndDateChoice($builder);
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
}
protected function addTemplateChoice(FormBuilderInterface $builder)
{
$builder->add('template', InvoiceTemplateType::class, [
'required' => true
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => InvoiceQuery::class,
'csrf_protection' => false,
]);
}
}

View File

@@ -1,40 +0,0 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Toolbar;
use App\Form\Type\PageSizeType;
use Symfony\Component\Form\FormBuilderInterface;
use App\Repository\Query\CustomerQuery;
/**
* Defines the base form used for all toolbars with pageSizes.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class PagedToolbarForm extends AbstractToolbarForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
/** @var CustomerQuery $query */
$query = $options['data'];
$builder
->add('pageSize', PageSizeType::class, [
'required' => false,
])
;
}
}

View File

@@ -11,12 +11,8 @@
namespace App\Form\Toolbar;
use App\Form\Toolbar\VisibilityToolbarForm;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use App\Form\Type\CustomerType;
use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
/**
@@ -24,7 +20,7 @@ use App\Repository\Query\ProjectQuery;
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class ProjectToolbarForm extends VisibilityToolbarForm
class ProjectToolbarForm extends AbstractToolbarForm
{
/**
@@ -32,19 +28,9 @@ class ProjectToolbarForm extends VisibilityToolbarForm
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$builder
->add('customer', CustomerType::class, [
'required' => false,
'query_builder' => function (CustomerRepository $repo) {
$query = new CustomerQuery();
$query->setVisibility(CustomerQuery::SHOW_BOTH); // this field is the reason for the query here
$query->setResultType(CustomerQuery::RESULT_TYPE_QUERYBUILDER);
return $repo->findByQuery($query);
},
])
;
$this->addPageSizeChoice($builder);
$this->addVisibilityChoice($builder);
$this->addCustomerChoice($builder);
}
/**

View File

@@ -11,28 +11,26 @@
namespace App\Form\Toolbar;
use App\Form\Type\VisibilityType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* Defines the form used for filtering entities with a "visibility" field.
* Defines the form used for filtering the admin timesheet.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class VisibilityToolbarForm extends PagedToolbarForm
class TimesheetAdminToolbarForm extends TimesheetToolbarForm
{
/**
* {@inheritdoc}
* @inheritdoc
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$builder
->add('visibility', VisibilityType::class, [
'required' => false,
])
;
$this->addTimesheetStateChoice($builder);
$this->addPageSizeChoice($builder);
$this->addUserChoice($builder);
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
}
}

View File

@@ -14,7 +14,6 @@ namespace App\Form\Toolbar;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use App\Form\Type\ActivityType;
use App\Repository\Query\TimesheetQuery;
/**
@@ -22,7 +21,7 @@ use App\Repository\Query\TimesheetQuery;
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class TimesheetToolbarForm extends ActivityToolbarForm
class TimesheetToolbarForm extends AbstractToolbarForm
{
/**
@@ -30,42 +29,26 @@ class TimesheetToolbarForm extends ActivityToolbarForm
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('state', ChoiceType::class, [
'label' => 'label.entryState',
'choices' => [
'entryState.all' => TimesheetQuery::STATE_ALL,
'entryState.running' => TimesheetQuery::STATE_RUNNING,
'entryState.stopped' => TimesheetQuery::STATE_STOPPED
],
])
;
parent::buildForm($builder, $options);
$this->addActivityChoice($builder, $options['data']);
$builder->remove('visibility');
$this->addTimesheetStateChoice($builder);
$this->addPageSizeChoice($builder);
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
}
/**
* @param FormBuilderInterface $builder
* @param TimesheetQuery $query
*/
protected function addActivityChoice(FormBuilderInterface $builder, TimesheetQuery $query)
protected function addTimesheetStateChoice(FormBuilderInterface $builder)
{
if ($query->getProject() === null) {
return;
}
$choices = [];
foreach ($query->getProject()->getActivities() as $activity) {
$choices[] = $activity;
}
$builder
->add('activity', ActivityType::class, [
'required' => false,
'choices' => $choices,
]);
$builder->add('state', ChoiceType::class, [
'label' => 'label.entryState',
'choices' => [
'entryState.all' => TimesheetQuery::STATE_ALL,
'entryState.running' => TimesheetQuery::STATE_RUNNING,
'entryState.stopped' => TimesheetQuery::STATE_STOPPED
],
]);
}
/**

View File

@@ -11,7 +11,6 @@
namespace App\Form\Toolbar;
use App\Form\Type\UserRoleType;
use App\Repository\Query\UserQuery;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -21,7 +20,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class UserToolbarForm extends VisibilityToolbarForm
class UserToolbarForm extends AbstractToolbarForm
{
/**
@@ -29,13 +28,9 @@ class UserToolbarForm extends VisibilityToolbarForm
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$builder
->add('role', UserRoleType::class, [
'required' => false,
])
;
$this->addPageSizeChoice($builder);
$this->addVisibilityChoice($builder);
$this->addUserRoleChoice($builder);
}
/**

View File

@@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use App\Invoice\ServiceInvoice;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to select an invoice calculator.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class InvoiceCalculatorType extends AbstractType
{
/**
* @var ServiceInvoice
*/
protected $service;
/**
* InvoiceCalculatorType constructor.
* @param ServiceInvoice $service
*/
public function __construct(ServiceInvoice $service)
{
$this->service = $service;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$renderer = [];
foreach ($this->service->getCalculator() as $name => $class) {
$renderer[$name] = $name;
}
$resolver->setDefaults([
'label' => 'label.invoice_calculator',
'choices' => $renderer,
'choice_label' => function($renderer) {
return 'invoice_calculator.' . $renderer;
}
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return ChoiceType::class;
}
}

View File

@@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use App\Invoice\ServiceInvoice;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to select an invoice number generator.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class InvoiceNumberGeneratorType extends AbstractType
{
/**
* @var ServiceInvoice
*/
protected $service;
/**
* InvoiceNumberGeneratorType constructor.
* @param ServiceInvoice $service
*/
public function __construct(ServiceInvoice $service)
{
$this->service = $service;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$renderer = [];
foreach ($this->service->getNumberGenerator() as $name => $class) {
$renderer[$name] = $name;
}
$resolver->setDefaults([
'label' => 'label.invoice_number_generator',
'choices' => $renderer,
'choice_label' => function($renderer) {
return 'invoice_number_generator.' . $renderer;
}
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return ChoiceType::class;
}
}

View File

@@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use App\Invoice\ServiceInvoice;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to select an invoice renderer.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class InvoiceRendererType extends AbstractType
{
/**
* @var ServiceInvoice
*/
protected $service;
/**
* InvoiceRendererType constructor.
* @param ServiceInvoice $service
*/
public function __construct(ServiceInvoice $service)
{
$this->service = $service;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$renderer = [];
foreach ($this->service->getRenderer() as $name => $action) {
$renderer[$name] = $name;
}
$resolver->setDefaults([
'label' => 'label.invoice_renderer',
'choices' => $renderer,
'choice_label' => function($renderer) {
return 'invoice_renderer.' . $renderer;
}
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return ChoiceType::class;
}
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use App\Entity\InvoiceTemplate;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to select an invoice template.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class InvoiceTemplateType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'label' => 'label.template',
'class' => InvoiceTemplate::class,
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return EntityType::class;
}
}

View File

@@ -16,7 +16,6 @@ use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use App\Entity\Project;
use App\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery;
/**
* Custom form field type to select a project.
@@ -33,7 +32,7 @@ class ProjectType extends AbstractType
{
$resolver->setDefaults([
'label' => 'label.project',
'class' => 'Kimai:Project',
'class' => Project::class,
'choice_label' => 'name',
'group_by' => function (Project $project, $key, $index) {
return $project->getCustomer()->getName();

View File

@@ -32,6 +32,7 @@ class VisibilityType extends AbstractType
$resolver->setDefaults([
'label' => 'label.visible',
'choices' => [
'both' => VisibilityQuery::SHOW_BOTH,
'yes' => VisibilityQuery::SHOW_VISIBLE,
'no' => VisibilityQuery::SHOW_HIDDEN,
],

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice;
use App\Model\InvoiceModel;
/**
* CalculatorInterface defines all methods for any invoice price calculator.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
interface CalculatorInterface
{
/**
* @param InvoiceModel $model
*/
public function setModel(InvoiceModel $model);
/**
* @return float
*/
public function getSubtotal(): float;
/**
* @return float
*/
public function getTax(): float;
/**
* @return float
*/
public function getTotal(): float;
/**
* @return string
*/
public function getCurrency(): string;
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice;
use App\Model\InvoiceModel;
/**
* Class DateNumberGenerator generates the invoice number based on the current day.
* It will create duplicate IDs if you create multiple invoices per day.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class DateNumberGenerator implements NumberGeneratorInterface
{
/**
* @var InvoiceModel
*/
protected $model;
/**
* @param InvoiceModel $model
*/
public function setModel(InvoiceModel $model)
{
$this->model = $model;
}
/**
* @return string
*/
public function getInvoiceNumber(): string
{
return date('ymd');
}
}

View File

@@ -0,0 +1,94 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice;
use App\Model\InvoiceModel;
/**
* Class DefaultCalculator works on all given entries using:
* - the customers currency
* - the invoice template vat rate
* - the entries rate
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class DefaultCalculator implements CalculatorInterface
{
/**
* @var string
*/
protected $currency;
/**
* @var InvoiceModel
*/
protected $model;
/**
* @param InvoiceModel $model
*/
public function setModel(InvoiceModel $model)
{
$this->model = $model;
}
/**
* @return float
*/
public function getSubtotal(): float
{
$amount = 0;
foreach ($this->model->getEntries() as $entry) {
$amount += $entry->getRate();
}
return round($amount, 2);
}
/**
* @return float
*/
public function getVat()
{
return $this->model->getTemplate()->getVat();
}
/**
* @return float
*/
public function getTax(): float
{
$vat = $this->getVat();
if ($vat == 0) {
return 0;
}
$percent = $vat / 100.00;
return round($this->getSubtotal() * $percent, 2);
}
/**
* @return float
*/
public function getTotal(): float
{
return $this->getSubtotal() + $this->getTax();
}
/**
* @return string
*/
public function getCurrency(): string
{
return $this->model->getCustomer()->getCurrency();
}
}

View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice;
use App\Model\InvoiceModel;
/**
* Class NumberGeneratorInterface defines all methods that invoice number generator have to implement.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
interface NumberGeneratorInterface
{
/**
* @param InvoiceModel $model
*/
public function setModel(InvoiceModel $model);
/**
* @return string
*/
public function getInvoiceNumber(): string;
}

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice;
use App\Model\InvoiceModel;
/**
* Class RandomNumberGenerator is meant for testing purpose only.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class RandomNumberGenerator implements NumberGeneratorInterface
{
/**
* @var InvoiceModel
*/
protected $model;
/**
* @param InvoiceModel $model
*/
public function setModel(InvoiceModel $model)
{
$this->model = $model;
}
/**
* @return string
*/
public function getInvoiceNumber(): string
{
return rand(1000000, 9999999);
}
}

View File

@@ -0,0 +1,106 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice;
/**
* A service to manage the invoice configuration:
* - invoice number generator
* - invoice sum calculator
* - template renderer
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class ServiceInvoice
{
/**
* @var array
*/
protected $config = [];
/**
* ServiceInvoice constructor.
* @param array $invoiceConfig
*/
public function __construct(array $invoiceConfig)
{
$this->config = $invoiceConfig;
}
/**
* @return array
*/
public function getNumberGenerator()
{
return $this->config['number_generator'];
}
/**
* @param string $name
* @return NumberGeneratorInterface|null
*/
public function getNumberGeneratorByName(string $name)
{
foreach ($this->getNumberGenerator() as $key => $class) {
if ($key === $name) {
return new $class();
}
}
return null;
}
/**
* @return array
*/
public function getCalculator()
{
return $this->config['calculator'];
}
/**
* @param string $name
* @return CalculatorInterface|null
*/
public function getCalculatorByName(string $name)
{
foreach ($this->getCalculator() as $key => $class) {
if ($key === $name) {
return new $class();
}
}
return null;
}
/**
* Returns an array of invoice renderer, which will consist of a unique name and a controller action.
*
* @return array
* @throws \Exception
*/
public function getRenderer()
{
return $this->config['renderer'];
}
/**
* @param $renderer
* @return string|null
*/
public function getRendererActionByName($renderer)
{
foreach ($this->config['renderer'] as $name => $action) {
if ($name == $renderer) {
return $action;
}
}
return null;
}
}

View File

@@ -2,6 +2,7 @@
namespace App;
use App\DependencyInjection\AppExtension;
use App\DependencyInjection\Compiler\DoctrineCompilerPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\Config\Loader\LoaderInterface;
@@ -38,6 +39,8 @@ class Kernel extends BaseKernel
protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader)
{
$container->registerExtension(new AppExtension());
$container->setParameter('container.autowiring.strict_mode', true);
$container->setParameter('container.dumper.inline_class_loader', true);
$confDir = $this->getProjectDir().'/config';

186
src/Model/InvoiceModel.php Normal file
View File

@@ -0,0 +1,186 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Entity\Timesheet;
use App\Invoice\CalculatorInterface;
use App\Invoice\DateNumberGenerator;
use App\Invoice\DefaultCalculator;
use App\Invoice\NumberGeneratorInterface;
use App\Repository\Query\InvoiceQuery;
/**
* Class InvoiceModel is the ONLY value that a renderer template receives for generating the invoice.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class InvoiceModel
{
/**
* @var Customer
*/
protected $customer;
/**
* @var InvoiceQuery
*/
protected $query;
/**
* @var Timesheet[]
*/
protected $entries;
/**
* @var InvoiceTemplate
*/
protected $template;
/**
* @var CalculatorInterface
*/
protected $calculator;
/**
* @var NumberGeneratorInterface
*/
protected $generator;
/**
* @return InvoiceQuery
*/
public function getQuery(): InvoiceQuery
{
return $this->query;
}
/**
* @param InvoiceQuery $query
* @return InvoiceModel
*/
public function setQuery(InvoiceQuery $query)
{
$this->query = $query;
return $this;
}
/**
* @return Timesheet[]
*/
public function getEntries(): array
{
return $this->entries;
}
/**
* @param Timesheet[] $entries
* @return InvoiceModel
*/
public function setEntries(array $entries)
{
$this->entries = $entries;
return $this;
}
/**
* @return InvoiceTemplate
*/
public function getTemplate(): ?InvoiceTemplate
{
return $this->template;
}
/**
* @param InvoiceTemplate $template
* @return InvoiceModel
*/
public function setTemplate($template)
{
$this->template = $template;
return $this;
}
/**
* @return Customer
*/
public function getCustomer()
{
return $this->customer;
}
/**
* @param Customer $customer
* @return InvoiceModel
*/
public function setCustomer($customer)
{
$this->customer = $customer;
return $this;
}
/**
* @return \DateTime
*/
public function getDueDate(): \DateTime
{
return new \DateTime('+'.$this->getTemplate()->getDueDays().' days');
}
/**
* @return \DateTime
*/
public function getInvoiceDate(): \DateTime
{
return new \DateTime();
}
/**
* @param CalculatorInterface $calculator
* @return InvoiceModel
*/
public function setCalculator(CalculatorInterface $calculator)
{
$this->calculator = $calculator;
$this->calculator->setModel($this);
return $this;
}
/**
* @param NumberGeneratorInterface $generator
* @return InvoiceModel
*/
public function setNumberGenerator(NumberGeneratorInterface $generator)
{
$this->generator = $generator;
$this->generator->setModel($this);
return $this;
}
/**
* @return NumberGeneratorInterface
*/
public function getNumberGenerator(): NumberGeneratorInterface
{
return $this->generator;
}
/**
* @return CalculatorInterface
*/
public function getCalculator(): CalculatorInterface
{
return $this->calculator;
}
}

View File

@@ -0,0 +1,60 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository;
use App\Entity\InvoiceTemplate;
use App\Repository\Query\BaseQuery;
use Doctrine\ORM\Query;
/**
* Class InvoiceTemplateRepository
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class InvoiceTemplateRepository extends AbstractRepository
{
/**
* @return bool
*/
public function hasTemplate()
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('COUNT(t.id) as totalRecords')
->from(InvoiceTemplate::class, 't')
;
$result = $qb->getQuery()->execute([], Query::HYDRATE_ARRAY);
if (!isset($result[0])) {
return false;
}
return $result[0]['totalRecords'] > 0;
}
/**
* @param BaseQuery $query
* @return \Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta
*/
public function findByQuery(BaseQuery $query)
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('t')
->from(InvoiceTemplate::class, 't')
->orderBy('t.id');
return $this->getBaseQueryResult($qb, $query);
}
}

View File

@@ -19,7 +19,7 @@ use App\Entity\Project;
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class ActivityQuery extends VisibilityQuery
class ActivityQuery extends ProjectQuery
{
/**
@@ -27,29 +27,6 @@ class ActivityQuery extends VisibilityQuery
*/
protected $project;
/**
* @var Customer
*/
protected $customer;
/**
* @return Customer
*/
public function getCustomer()
{
return $this->customer;
}
/**
* @param Customer $customer
* @return $this
*/
public function setCustomer(Customer $customer = null)
{
$this->customer = $customer;
return $this;
}
/**
* @return Project
*/

View File

@@ -0,0 +1,69 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Query;
use App\Entity\InvoiceTemplate;
/**
* Can be used for invoice queries.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class InvoiceQuery extends TimesheetQuery
{
/**
* @var InvoiceTemplate
*/
protected $template;
/**
* @var InvoiceTemplate[]
*/
protected $templates = [];
/**
* @return InvoiceTemplate
*/
public function getTemplate()
{
return $this->template;
}
/**
* @param InvoiceTemplate $template
* @return InvoiceQuery
*/
public function setTemplate($template)
{
$this->template = $template;
return $this;
}
/**
* @return InvoiceTemplate[]
*/
public function getTemplates(): array
{
return $this->templates;
}
/**
* @param InvoiceTemplate[] $templates
* @return InvoiceQuery
*/
public function setTemplates(array $templates)
{
$this->templates = $templates;
return $this;
}
}

View File

@@ -22,7 +22,7 @@ use App\Entity\Project;
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class TimesheetQuery extends BaseQuery
class TimesheetQuery extends ActivityQuery
{
const STATE_ALL = 1;
@@ -47,18 +47,18 @@ class TimesheetQuery extends BaseQuery
* @var Activity
*/
protected $activity;
/**
* @var Project
*/
protected $project;
/**
* @var Customer
*/
protected $customer;
/**
* @var int
*/
protected $state = self::STATE_ALL;
/**
* @var \DateTime
*/
protected $begin;
/**
* @var \DateTime
*/
protected $end;
/**
* @return User
@@ -98,48 +98,6 @@ class TimesheetQuery extends BaseQuery
return $this;
}
/**
* @return Project
*/
public function getProject()
{
return $this->project;
}
/**
* Project overwrites: setCustomer()
* Is overwritten by: setActivity()
*
* @param Project $project
* @return TimesheetQuery
*/
public function setProject(Project $project = null)
{
$this->project = $project;
return $this;
}
/**
* @return Customer
*/
public function getCustomer()
{
return $this->customer;
}
/**
* Project overwrites: none
* Is overwritten by: setActivity() and setProject()
*
* @param Customer $customer
* @return TimesheetQuery
*/
public function setCustomer(Customer $customer = null)
{
$this->customer = $customer;
return $this;
}
/**
* @return int
*/
@@ -165,4 +123,40 @@ class TimesheetQuery extends BaseQuery
return $this;
}
/**
* @return \DateTime
*/
public function getBegin()
{
return $this->begin;
}
/**
* @param \DateTime $begin
* @return TimesheetQuery
*/
public function setBegin($begin)
{
$this->begin = $begin;
return $this;
}
/**
* @return \DateTime
*/
public function getEnd()
{
return $this->end;
}
/**
* @param \DateTime $end
* @return TimesheetQuery
*/
public function setEnd($end)
{
$this->end = $end;
return $this;
}
}

View File

@@ -15,6 +15,7 @@ use App\Entity\User;
use App\Entity\Activity;
use App\Entity\Timesheet;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
use App\Model\Statistic\Month;
use App\Model\Statistic\Year;
@@ -266,7 +267,7 @@ class TimesheetRepository extends AbstractRepository
/**
* @param TimesheetQuery $query
* @return Pagerfanta
* @return QueryBuilder|Pagerfanta
*/
public function findByQuery(TimesheetQuery $query)
{
@@ -291,6 +292,15 @@ class TimesheetRepository extends AbstractRepository
$qb->andWhere($qb->expr()->isNotNull('t.end'));
}
if ($query->getBegin() !== null) {
$qb->andWhere('t.begin >= :begin')
->setParameter('begin', $query->getBegin());
}
if ($query->getEnd() !== null) {
$qb->andWhere('t.end <= :end')
->setParameter('end', $query->getEnd());
}
if ($query->getActivity() !== null) {
$qb->andWhere('t.activity = :activity')
->setParameter('activity', $query->getActivity());

View File

@@ -23,6 +23,14 @@ use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
*/
class UserRepository extends AbstractRepository implements UserLoaderInterface
{
/**
* @param $id
* @return null|User
*/
public function getById($id)
{
return $this->find($id);
}
/**
* Return statistic data for all user.

View File

@@ -11,9 +11,7 @@
namespace App\Twig;
use App\Utils\Markdown;
use Symfony\Component\Intl\Intl;
use App\Entity\Customer;
use App\Entity\Timesheet;
/**
@@ -123,8 +121,11 @@ class Extensions extends \Twig_Extension
*/
public function money($amount, $currency = null)
{
$currency = $currency ?: Customer::DEFAULT_CURRENCY;
return round($amount) . ' ' . Intl::getCurrencyBundle()->getCurrencySymbol($currency);
$result = round($amount, 2);
if ($currency !== null) {
$result .= ' ' . Intl::getCurrencyBundle()->getCurrencySymbol($currency);
}
return $result;
}
/**

View File

@@ -11,11 +11,9 @@
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% endif %}
{# Available fields: vat; contact; address; country; phone; fax; mobile; mail; homepage; timezone; #}
{{ tables.data_table_header({
'label.id': 'hidden-xs',
'label.name': '',
'label.customer_number': 'hidden-xs',
'label.project': '',
'label.comment': 'hidden-xs',
'label.country': 'hidden-xs',
@@ -26,8 +24,8 @@
{% for entry in entries %}
<tr>
<td class="hidden-xs">{{ entry.id }}</td>
<td>{{ entry.name }} {% if entry.company is not empty %}({{ entry.company }}){% endif %}</td>
<td class="hidden-xs">{{ entry.number }}</td>
<td>
{% for project in entry.projects %}
<a href="{{ path('admin_project_edit', {'id' : project.id}) }}">{{ widgets.label_project(project) }}</a>

View File

@@ -1,5 +1,15 @@
{% extends '@AvanzuAdminTheme/layout/default-layout.html.twig' %}
{% block avanzu_page_content_before %}
<div class="pad margin no-print">
{% block main_before %}{% endblock %}
</div>
{% endblock %}
{% block avanzu_page_content_after %}
{% block main_after %}{% endblock %}
{% endblock %}
{% block avanzu_page_content %}
{{ include('default/_flash_messages.html.twig') }}
{% block main %}{% endblock %}
@@ -31,16 +41,17 @@
{% block avanzu_head %}
<link rel="stylesheet" href="{{ asset('css/kimai.css') }}">
{#<link rel="stylesheet" href="{{ asset('theme/plugins/datepicker/datepicker3.css') }}">#}
<script src="https://code.jquery.com/jquery-2.2.4.min.js"></script>
{% endblock %}
{% block avanzu_footer %}
{% block footer %}
<footer class="main-footer">
<footer class="main-footer no-print">
<div class="pull-right hidden-xs">
<b>Version</b> 2.0
<b>Version</b> 2.0 dev
</div>
<strong>Copyright &copy; {{ 'now'|date('Y') }} - <a href="http://www.kevinpapst.de">Kevin Papst</a></strong>.
Made with ♥ by <a href="http://www.kevinpapst.de">Kevin Papst</a>.
{{ 'footer.license'|trans }}
</footer>
<!-- Page rendered on {{ 'now'|localizeddate('long', 'long', null, 'UTC') }} -->
@@ -74,6 +85,8 @@
{% block avanzu_javascripts %}
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js"></script>
<script src="{{ asset('theme/bootstrap/js/bootstrap.min.js') }}"></script>
{#<script src="{{ asset('theme/plugins/daterangepicker/moment.min.js') }}"></script>
<script src="{{ asset('theme/plugins/datepicker/bootstrap-datepicker.js') }}"></script>#}
<script src="{{ asset('js/kimai.js') }}"></script>
<script src="{{ asset('theme/plugins/slimScroll/jquery.slimscroll.min.js') }}"></script>
<script src="{{ asset('js/Chart.min.js') }}"></script>

View File

@@ -1,19 +0,0 @@
{#
Each field type is rendered by a template fragment, which is determined
by the name of your form type class (DateTimePickerType -> date_time_picker)
and the suffix "_widget". This can be controlled by overriding getBlockPrefix()
in DateTimePickerType.
See http://symfony.com/doc/current/cookbook/form/create_custom_field_type.html#creating-a-template-for-the-field
#}
{% block date_time_picker_widget %}
{% spaceless %}
<div class="input-group date" data-toggle="datetimepicker">
{{ block('datetime_widget') }}
<span class="input-group-addon">
<span class="fa fa-calendar"></span>
</span>
</div>
{% endspaceless %}
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends "@AvanzuAdminTheme/layout/form-theme.html.twig" %}
{% block date_widget %}
{% if widget == 'single_text' %}
{% if attr.class is defined %}
{% set class = attr.class ~ ' timepicker' %}
{% else %}
{% set class = ' timepicker' %}
{% endif %}
{% set class = class ~ ' datepicker' %}
{% set attr = attr|merge({'class' : class, 'data-datepickerenable':'on'}) %}
{{ block('form_widget_simple') }}
{% else %}
{% set date_pattern = '<div class="row">' ~ date_pattern ~ '</div>'|raw %}
{{ date_pattern|replace({
'{{ year }}' : '<div class="col-xs-4">{{ year }}</div>',
'{{ month }}' : '<div class="col-xs-4">{{ month }}</div>',
'{{ day }}' : '<div class="col-xs-4">{{ day }}</div>',
})|raw|replace({
'{{ year }}': form_widget(form.year),
'{{ month }}': form_widget(form.month),
'{{ day }}': form_widget(form.day),
})|raw }}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/toolbar.html.twig" as macro %}
{% block page_title %}{{ 'invoice.title'|trans }}{% endblock %}
{% block page_subtitle %}{{ 'invoice.subtitle'|trans }}{% endblock %}
{% block javascript_imports %}<script src="{{ asset('js/toolbar.js') }}"></script>{% endblock %}
{% block avanzu_page_content_class %}invoice{% endblock %}
{% block main_before %}
{{ macro.toolbar(form, {'plus-square': path('admin_invoice_template_create'), 'list': path('admin_invoice_template')}) }}
{% endblock %}
{% block main_after %}
<div class="clearfix"></div>
{% endblock %}
{% block main %}
{% if model.entries is empty or action is empty %}
{{ widgets.callout('warning', 'invoice.select_filter') }}
{% else %}
{{ render(controller(action, {'model': model})) }}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,111 @@
<div class="row">
<div class="col-xs-12">
<h2 class="page-header">
<i class="fa fa-globe"></i> <span contenteditable="true">{{ model.template.title }}</span>
<small class="pull-right">{{ 'label.date'|trans }}: {{ model.invoiceDate|date(kimai_context.date_1) }}</small>
</h2>
</div>
</div>
<div class="row invoice-info">
<div class="col-sm-4 invoice-col">
{{ 'invoice.from'|trans }}
<address contenteditable="true">
<strong>{{ model.template.company }}</strong><br>
{{ model.template.address|trim|nl2br }}
</address>
</div>
<div class="col-sm-4 invoice-col">
{% set customerPhone = model.customer.phone|default(model.customer.mobile) %}
{{ 'invoice.to'|trans }}
<address contenteditable="true">
<strong>{{ model.customer.company|default(model.customer.name) }}</strong><br>
{{ model.customer.address|nl2br }}
{% if model.customer.phone is not empty %}
<br>
{{ 'label.phone'|trans }}: {{ model.customer.phone }}
{% endif %}
{% if model.customer.mail is not empty %}
<br>
{{ 'label.email'|trans }}: {{ model.customer.mail }}
{% endif %}
</address>
</div>
<div class="col-sm-4 invoice-col">
<p contenteditable="true">
<b>{{ 'invoice.number'|trans }} {{ model.numberGenerator.invoiceNumber }}</b>
</p>
<p contenteditable="true">
<b>{{ 'invoice.due_days'|trans }}:</b> {{ model.dueDate|date(kimai_context.date_1) }}<br>
<b>{{ 'label.customer_number'|trans }}:</b> {{ model.customer.number }}<br>
</p>
</div>
</div>
<div class="row">
<div class="col-xs-12 table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>{{ 'label.date'|trans }}</th>
<th>{{ 'label.activity'|trans }}</th>
<th>{{ 'label.hours'|trans }}</th>
<th>{{ 'label.rate'|trans }}</th>
</tr>
</thead>
<tbody>
{% for entry in model.entries %}
<tr>
<td>{{ entry.begin|date(kimai_context.date_1) }}</td>
<td>{{ entry.activity.name }} / {{ entry.activity.project.name }}</td>
<td>{{ entry.duration|duration }}</td>
<td>{{ entry.rate|money(model.calculator.currency) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="row">
<div class="col-xs-6">
{% if model.template.paymentTerms is not empty %}
<p class="lead">{{ 'label.payment_terms'|trans }}</p>
<p class="text-muted well well-sm no-shadow" contenteditable="true">
{{ model.template.paymentTerms|trim|nl2br }}
</p>
{% endif %}
</div>
<div class="col-xs-6">
<p class="lead" contenteditable="true">{{ 'invoice.due_days'|trans }} {{ model.dueDate|date(kimai_context.date_1) }}</p>
<div class="table-responsive">
<table class="table">
<tbody>
<tr>
<th style="width:50%">{{ 'invoice.subtotal'|trans }}</th>
<td>{{ model.calculator.subtotal|money(model.calculator.currency) }}</td>
</tr>
<tr>
<th>{{ 'invoice.tax'|trans }} ({{ model.calculator.vat }}%)</th>
<td>{{ model.calculator.tax|money(model.calculator.currency) }}</td>
</tr>
<tr>
<th>{{ 'invoice.total'|trans }}</th>
<td>{{ model.calculator.total|money(model.calculator.currency) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="row no-print">
<div class="col-xs-12">
<button type="button" class="btn btn-success pull-right" onclick="window.print();return false;" style="margin-right: 5px;">
<i class="fa fa-print"></i> {{ 'button.print'|trans }}
</button>
</div>
</div>

View File

@@ -0,0 +1,12 @@
{% extends 'base.html.twig' %}
{% block page_title %}{{ 'admin_invoice_template.title'|trans }}{% endblock %}
{% block page_subtitle %}{{ 'admin_invoice_template.subtitle'|trans }}{% endblock %}
{% block main %}
{{ include('default/_form.html.twig', {
'title': template.name|default('create'|trans),
'form': form,
'back': path('admin_invoice_template')
}) }}
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/datatables.html.twig" as tables %}
{% block page_title %}{{ 'admin_invoice_template.title'|trans }}{% endblock %}
{% block page_subtitle %}{{ 'admin_invoice_template.subtitle'|trans }}{% endblock %}
{% block main %}
{% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% endif %}
{{ tables.data_table_header({
'label.name': '',
'label.title': '',
'label.due_days': 'hidden-xs',
'label.vat': 'hidden-xs',
'label.invoice_renderer': 'hidden-xs',
'label.actions': '',
}) }}
{% for entry in entries %}
<tr>
<td>{{ entry.name }}</td>
<td>{{ entry.title }}</td>
<td class="hidden-xs hidden-sm">{{ entry.dueDays }}</td>
<td class="hidden-xs hidden-sm">{{ entry.vat }}</td>
<td class="hidden-xs hidden-sm">{{ "invoice_renderer.#{entry.renderer}"|trans }}</td>
<td>
{% set actionButtons = {'edit': path('admin_invoice_template_edit', {'id' : entry.id, 'page': page})} %}
{{ widgets.button_group(actionButtons) }}
</td>
</tr>
{% endfor %}
{{ tables.data_table_footer(entries, 'admin_invoice_template_paginated') }}
{% endblock %}

View File

@@ -2,33 +2,8 @@
{% macro data_table_header(entries, form, tools, toolsRight) %}
<div class="box data_table">
{% if form or tools or toolsRight %}
<div class="box-header with-border">
{% if tools %}
<div class="btn-group tools-left">
{% for icon,url in tools %}
<a class="btn btn-default" href="{{ url }}"><i class="fa fa-{{ icon }}"></i></a>
{% endfor %}
</div>
{% endif %}
{% if form %}
<div class="box-title toolbar">
{{ form_start(form) }}
{{ form_widget(form) }}
{{ form_end(form) }}
</div>
{% endif %}
{% if toolsRight %}
<div class="box-tools">
<div class="btn-group">
{% for icon,url in tools %}
<a class="btn btn-default" href="{{ url }}"><i class="fa fa-{{ icon }}"></i></a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% import "macros/toolbar.html.twig" as macro %}
{{ macro.toolbar(form, tools, toolsRight) }}
{% endif %}
<div class="box-body no-padding">
<div class="dataTables_wrapper form-inline dt-bootstrap">
@@ -72,7 +47,7 @@
</div>
</div>
<div class="navigation text-center">
<div class="navigation text-center no-print">
{{ pagerfanta(entries, 'twitter_bootstrap3_translated', { routeName: route }) }}
</div>
{% endmacro %}

View File

@@ -0,0 +1,31 @@
{% macro toolbar(form, tools, toolsRight) %}
<div class="box-header">
{% if tools %}
<div class="btn-group tools-left no-print">
{% for icon,url in tools %}
<a class="btn btn-default" href="{{ url }}"><i class="fa fa-{{ icon }}"></i></a>
{% endfor %}
</div>
{% endif %}
{% if form %}
<div class="box-title toolbar no-print">
{% form_theme form 'form/toolbar-theme.html.twig' %}
{{ form_start(form) }}
{{ form_widget(form) }}
{{ form_end(form) }}
</div>
{% endif %}
{% if toolsRight %}
<div class="box-tools">
<div class="btn-group">
{% for icon,url in tools %}
<a class="btn btn-default" href="{{ url }}"><i class="fa fa-{{ icon }}"></i></a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endmacro %}

View File

@@ -83,9 +83,9 @@
</div>
{% endmacro %}
{% macro callout(type, description, title) %}
{% macro callout(type, description, title, icon) %}
<div class="callout callout-{{ type|default('danger') }} lead">
{% if title %}<h4>{{ title|trans }}</h4>{% endif %}
{% if title %}<h4>{% if icon %}<i class="fa fa-{{ icon }}">{% endif %}</i> {{ title|trans }}</h4>{% endif %}
<p>{{ description|trans }}</p>
</div>
{% endmacro %}

View File

@@ -26,6 +26,10 @@
<source>action.deleted_successfully</source>
<target>Eintrag wurde erfolgreich gelöscht</target>
</trans-unit>
<trans-unit id="invoice.first_template">
<source>invoice.first_template</source>
<target>Sie müssen zunächst eine Rechnungsvorlage erstellen, bevor Sie fortfahren können</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -26,6 +26,10 @@
<source>action.deleted_successfully</source>
<target>Entry was deleted successful</target>
</trans-unit>
<trans-unit id="invoice.first_template">
<source>invoice.first_template</source>
<target>You have to create your first template invoice before you can proceed</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -17,6 +17,10 @@
<source>no</source>
<target>Nein</target>
</trans-unit>
<trans-unit id="both">
<source>both</source>
<target></target>
</trans-unit>
<trans-unit id="subtitle.amount">
<source>subtitle.amount</source>
<target>(insgesamt %count%)</target>
@@ -35,7 +39,9 @@
</trans-unit>
<trans-unit id="confirm.delete_message">
<source>confirm.delete_message</source>
<target>Sie müssen das Löschen erneut bestätigen, da verknüpfte Objekte existieren welche ebenfalls mit gelöscht werden.</target>
<target>
Sie müssen das Löschen erneut bestätigen, da verknüpfte Objekte existieren welche ebenfalls mit gelöscht werden.
</target>
</trans-unit>
<!--
@@ -85,6 +91,10 @@
<source>menu.timesheet</source>
<target>Zeiterfassung</target>
</trans-unit>
<trans-unit id="menu.invoice">
<source>menu.invoice</source>
<target>Rechnungen</target>
</trans-unit>
<trans-unit id="menu.admin_timesheet">
<source>menu.admin_timesheet</source>
<target>Mitarbeiter Zeiten</target>
@@ -99,7 +109,7 @@
</trans-unit>
<trans-unit id="menu.admin_activity">
<source>menu.admin_activity</source>
<target>Aktivitäten</target>
<target>Tätigkeiten</target>
</trans-unit>
<trans-unit id="menu.admin_user">
<source>menu.admin_user</source>
@@ -117,42 +127,6 @@
<!--
Error templates
-->
<trans-unit id="http_error.name">
<source>http_error.name</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error.description">
<source>http_error.description</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error.suggestion">
<source>http_error.suggestion</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error_403.description">
<source>http_error_403.description</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error_403.suggestion">
<source>http_error_403.suggestion</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error_404.description">
<source>http_error_404.description</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error_404.suggestion">
<source>http_error_404.suggestion</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error_405.description">
<source>http_error_405.description</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error_405.suggestion">
<source>http_error_405.suggestion</source>
<target>Error</target>
</trans-unit>
<trans-unit id="error.no_entries_found">
<source>error.no_entries_found</source>
<target>Keine Einträge vorhanden</target>
@@ -211,7 +185,7 @@
</trans-unit>
<trans-unit id="label.activity">
<source>label.activity</source>
<target>Aktivität</target>
<target>Tätigkeit</target>
</trans-unit>
<trans-unit id="label.project">
<source>label.project</source>
@@ -421,8 +395,8 @@
<source>admin_project.delete_confirm</source>
<target>
Momentan existieren für das Projekt %project% des Kunden %customer% insgesamt %activities%
Aktivitäten und %records% Zeiteinträge, welche sich auf eine Gesamtdauer von %duration% belaufen.
Diese Aktivitäten und Zeiteinträge werden ebenfalls mit gelöscht!
Tätigkeiten und %records% Zeiteinträge, welche sich auf eine Gesamtdauer von %duration% belaufen.
Diese Tätigkeiten und Zeiteinträge werden ebenfalls mit gelöscht!
</target>
</trans-unit>
@@ -431,7 +405,7 @@
-->
<trans-unit id="admin_activity.title">
<source>admin_activity.title</source>
<target>Aktivitäten</target>
<target>Tätigkeiten</target>
</trans-unit>
<trans-unit id="admin_activity.subtitle">
<source>admin_activity.subtitle</source>
@@ -440,7 +414,7 @@
<trans-unit id="admin_activity.delete_confirm">
<source>admin_activity.delete_confirm</source>
<target>
Momentan existieren für die Aktivität %activity% im Projekt %project% für den Kunden %customer%
Momentan existieren für die Tätigkeit %activity% im Projekt %project% für den Kunden %customer%
insgesamt %records% Zeiteinträge, welche sich auf eine Gesamtdauer von %duration% belaufen. Diese
Zeiteinträge werden ebenfalls mit gelöscht!
</target>
@@ -457,6 +431,10 @@
<source>admin_customer.subtitle</source>
<target>Ihre Kunden stellen die Basis dar, auf der Sie Projekte und Tätigkeiten anlegen können</target>
</trans-unit>
<trans-unit id="label.customer_number">
<source>label.customer_number</source>
<target>Kundennummer</target>
</trans-unit>
<trans-unit id="label.company">
<source>label.company</source>
<target>Unternehmensbezeichnung</target>
@@ -505,8 +483,8 @@
<source>admin_customer.delete_confirm</source>
<target>
Momentan existieren für den Kunden %customer% insgesamt %project% Projekte mit %activity%
Aktivitäten, die sich in %records% Zeiteinträgen auf eine Gesamtdauer von %duration% belaufen. Diese
Projekte, Aktivitäten und Zeiteinträge werden ebenfalls mit gelöscht!
Tätigkeiten, die sich in %records% Zeiteinträgen auf eine Gesamtdauer von %duration% belaufen. Diese
Projekte, Tätigkeiten und Zeiteinträge werden ebenfalls mit gelöscht!
</target>
</trans-unit>
@@ -607,7 +585,7 @@
</trans-unit>
<trans-unit id="stats.activitiesTotal">
<source>stats.activitiesTotal</source>
<target>Anzahl Aktivitäten</target>
<target>Anzahl Tätigkeiten</target>
</trans-unit>
<trans-unit id="stats.projectsTotal">
<source>stats.projectsTotal</source>
@@ -618,6 +596,106 @@
<target>Anzahl Kunden</target>
</trans-unit>
<!--
Invoice
-->
<trans-unit id="admin_invoice_template.title">
<source>admin_invoice_template.title</source>
<target>Rechnungsvorlagen</target>
</trans-unit>
<trans-unit id="admin_invoice_template.subtitle">
<source>admin_invoice_template.subtitle</source>
<target>Verwalten Sie Ihre Rechnungsvorlagen, mit deren Hilfe Sie ihre Daten exportieren können.</target>
</trans-unit>
<trans-unit id="invoice.title">
<source>invoice.title</source>
<target>Rechnungen</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Hier erstellen Sie Rechnungen für ihre aufgezeichneten Zeiten.</target>
</trans-unit>
<trans-unit id="invoice.select_filter">
<source>invoice.select_filter</source>
<target>Bevor Sie eine Rechnung erstellen können, müssen Sie mindestens einen Kunden-Filter auswählen.</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>Drucken</target>
</trans-unit>
<trans-unit id="invoice_print">
<source>invoice_print</source>
<target>Rechnung</target>
</trans-unit>
<trans-unit id="label.template">
<source>label.template</source>
<target>Vorlage</target>
</trans-unit>
<trans-unit id="label.due_days">
<source>label.due_days</source>
<target>Zahlungsziel in Tagen</target>
</trans-unit>
<trans-unit id="invoice.due_days">
<source>invoice.due_days</source>
<target>Zahlungsziel</target>
</trans-unit>
<trans-unit id="invoice.from">
<source>invoice.from</source>
<target>Von</target>
</trans-unit>
<trans-unit id="invoice.to">
<source>invoice.to</source>
<target>An</target>
</trans-unit>
<trans-unit id="invoice.number">
<source>invoice.number</source>
<target>Rechnung</target>
</trans-unit>
<trans-unit id="invoice.subtotal">
<source>invoice.subtotal</source>
<target>Netto</target>
</trans-unit>
<trans-unit id="invoice.tax">
<source>invoice.tax</source>
<target>Umsatzsteuer</target>
</trans-unit>
<trans-unit id="invoice.total">
<source>invoice.total</source>
<target>Rechnungsbetrag</target>
</trans-unit>
<trans-unit id="label.payment_terms">
<source>label.payment_terms</source>
<target>Zahlungsinformationen</target>
</trans-unit>
<trans-unit id="label.invoice_renderer">
<source>label.invoice_renderer</source>
<target>Rechnungsvorlage</target>
</trans-unit>
<trans-unit id="invoice_renderer.default">
<source>invoice_renderer.default</source>
<target>HTML Rechnung (Standard)</target>
</trans-unit>
<trans-unit id="label.invoice_number_generator">
<source>label.invoice_number_generator</source>
<target>Rechnungsnummern-Generator</target>
</trans-unit>
<trans-unit id="invoice_number_generator.default">
<source>invoice_number_generator.default</source>
<target>Datum (Standard)</target>
</trans-unit>
<trans-unit id="invoice_number_generator.random">
<source>invoice_number_generator.random</source>
<target>Zufallszahl (nur zum Testen)</target>
</trans-unit>
<trans-unit id="label.invoice_calculator">
<source>label.invoice_calculator</source>
<target>Zahlungsberechnung</target>
</trans-unit>
<trans-unit id="invoice_calculator.default">
<source>invoice_calculator.default</source>
<target>Stundenbasiert (Standard)</target>
</trans-unit>
<!--
Month names
-->
@@ -683,7 +761,9 @@
</trans-unit>
<trans-unit id="recent.activities">
<source>recent.activities</source>
<target>Ihr %count% zuletzt bearbeitete Aktivität|Eine ihrer %count% letzten Aktivitäten neustarten</target>
<target>
Ihr %count% zuletzt bearbeitete Tätigkeit|Eine ihrer %count% letzten Tätigkeiten neustarten
</target>
</trans-unit>
<trans-unit id="recent.activities.format">
<source>recent.activities.format</source>

View File

@@ -17,6 +17,10 @@
<source>no</source>
<target>No</target>
</trans-unit>
<trans-unit id="both">
<source>both</source>
<target></target>
</trans-unit>
<trans-unit id="subtitle.amount">
<source>subtitle.amount</source>
<target>(total %count%)</target>
@@ -87,6 +91,10 @@
<source>menu.timesheet</source>
<target>Timesheet</target>
</trans-unit>
<trans-unit id="menu.invoice">
<source>menu.invoice</source>
<target>Invoices</target>
</trans-unit>
<trans-unit id="menu.admin_timesheet">
<source>menu.admin_timesheet</source>
<target>User timesheets</target>
@@ -119,42 +127,6 @@
<!--
Error templates
-->
<trans-unit id="http_error.name">
<source>http_error.name</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error.description">
<source>http_error.description</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error.suggestion">
<source>http_error.suggestion</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error_403.description">
<source>http_error_403.description</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error_403.suggestion">
<source>http_error_403.suggestion</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error_404.description">
<source>http_error_404.description</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error_404.suggestion">
<source>http_error_404.suggestion</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error_405.description">
<source>http_error_405.description</source>
<target>Error</target>
</trans-unit>
<trans-unit id="http_error_405.suggestion">
<source>http_error_405.suggestion</source>
<target>Error</target>
</trans-unit>
<trans-unit id="error.no_entries_found">
<source>error.no_entries_found</source>
<target>No entries existing</target>
@@ -179,7 +151,7 @@
<source>label.duration</source>
<target>Duration</target>
</trans-unit>
<trans-unit id="label.usern">
<trans-unit id="label.user">
<source>label.user</source>
<target>User</target>
</trans-unit>
@@ -459,6 +431,10 @@
<source>admin_customer.subtitle</source>
<target>Your customer are the base for creating projects and activities</target>
</trans-unit>
<trans-unit id="label.customer_number">
<source>label.customer_number</source>
<target>Account</target>
</trans-unit>
<trans-unit id="label.company">
<source>label.company</source>
<target>Company name</target>
@@ -620,6 +596,106 @@
<target>Amount customers</target>
</trans-unit>
<!--
Invoice
-->
<trans-unit id="admin_invoice_template.title">
<source>admin_invoice_template.title</source>
<target>Invoice template</target>
</trans-unit>
<trans-unit id="admin_invoice_template.subtitle">
<source>admin_invoice_template.subtitle</source>
<target>Administrate your invoice templates which can be used to data export.</target>
</trans-unit>
<trans-unit id="invoice.title">
<source>invoice.title</source>
<target>Invoices</target>
</trans-unit>
<trans-unit id="invoice.subtitle">
<source>invoice.subtitle</source>
<target>Create invoices from your recorded timesheet entries.</target>
</trans-unit>
<trans-unit id="invoice.select_filter">
<source>invoice.select_filter</source>
<target>Before you can create an invoice, you have to select at least a customer filter.</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>Print</target>
</trans-unit>
<trans-unit id="invoice_print">
<source>invoice_print</source>
<target>Invoice</target>
</trans-unit>
<trans-unit id="label.template">
<source>label.template</source>
<target>Template</target>
</trans-unit>
<trans-unit id="label.due_days">
<source>label.due_days</source>
<target>Payment term in days</target>
</trans-unit>
<trans-unit id="invoice.due_days">
<source>invoice.due_days</source>
<target>Payment target</target>
</trans-unit>
<trans-unit id="invoice.from">
<source>invoice.from</source>
<target>From</target>
</trans-unit>
<trans-unit id="invoice.to">
<source>invoice.to</source>
<target>To</target>
</trans-unit>
<trans-unit id="invoice.number">
<source>invoice.number</source>
<target>Invoice</target>
</trans-unit>
<trans-unit id="invoice.subtotal">
<source>invoice.subtotal</source>
<target>Subtotal</target>
</trans-unit>
<trans-unit id="invoice.tax">
<source>invoice.tax</source>
<target>Tax</target>
</trans-unit>
<trans-unit id="invoice.total">
<source>invoice.total</source>
<target>Total</target>
</trans-unit>
<trans-unit id="label.payment_terms">
<source>label.payment_terms</source>
<target>Terms of payment</target>
</trans-unit>
<trans-unit id="label.invoice_renderer">
<source>label.invoice_renderer</source>
<target>Invoice template</target>
</trans-unit>
<trans-unit id="invoice_renderer.default">
<source>invoice_renderer.default</source>
<target>HTML Invoice (default)</target>
</trans-unit>
<trans-unit id="label.invoice_number_generator">
<source>label.invoice_number_generator</source>
<target>Invoicenumber-Generator</target>
</trans-unit>
<trans-unit id="invoice_number_generator.default">
<source>invoice_number_generator.default</source>
<target>Date (default)</target>
</trans-unit>
<trans-unit id="invoice_number_generator.random">
<source>invoice_number_generator.random</source>
<target>Random number (only for testing)</target>
</trans-unit>
<trans-unit id="label.invoice_calculator">
<source>label.invoice_calculator</source>
<target>Amount calculator</target>
</trans-unit>
<trans-unit id="invoice_calculator.default">
<source>invoice_calculator.default</source>
<target>Hourly rates (default)</target>
</trans-unit>
<!--
Month names
-->