diff --git a/src/Controller/ActivityController.php b/src/Controller/ActivityController.php index 2cbc9ec6..24e7c65d 100644 --- a/src/Controller/ActivityController.php +++ b/src/Controller/ActivityController.php @@ -16,6 +16,9 @@ use App\Entity\MetaTableTypeInterface; use App\Entity\Project; use App\Event\ActivityMetaDefinitionEvent; use App\Event\ActivityMetaDisplayEvent; +use App\Export\Spreadsheet\EntityWithMetaFieldsExporter; +use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter; +use App\Export\Spreadsheet\Writer\XlsxWriter; use App\Form\ActivityEditForm; use App\Form\ActivityRateForm; use App\Form\Toolbar\ActivityToolbarForm; @@ -256,6 +259,34 @@ final class ActivityController extends AbstractController ); } + /** + * @Route(path="/export", name="activity_export", methods={"GET"}) + */ + public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter) + { + $query = new ActivityQuery(); + $query->setCurrentUser($this->getUser()); + + $form = $this->getToolbarForm($query); + $form->setData($query); + $form->submit($request->query->all(), false); + + if (!$form->isValid()) { + $query->resetByFormError($form->getErrors()); + } + + $entries = $this->repository->getActivitiesForQuery($query); + + $spreadsheet = $exporter->export( + Activity::class, + $entries, + new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT) + ); + $writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-activities'); + + return $writer->getFileResponse($spreadsheet); + } + /** * @param Activity $activity * @param Request $request diff --git a/src/Controller/CustomerController.php b/src/Controller/CustomerController.php index 9dc09d9e..d53465e0 100644 --- a/src/Controller/CustomerController.php +++ b/src/Controller/CustomerController.php @@ -14,10 +14,12 @@ use App\Entity\Customer; use App\Entity\CustomerComment; use App\Entity\CustomerRate; use App\Entity\MetaTableTypeInterface; -use App\Entity\Rate; use App\Entity\Team; use App\Event\CustomerMetaDefinitionEvent; use App\Event\CustomerMetaDisplayEvent; +use App\Export\Spreadsheet\EntityWithMetaFieldsExporter; +use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter; +use App\Export\Spreadsheet\Writer\XlsxWriter; use App\Form\CustomerCommentForm; use App\Form\CustomerEditForm; use App\Form\CustomerRateForm; @@ -407,6 +409,34 @@ final class CustomerController extends AbstractController ]); } + /** + * @Route(path="/export", name="customer_export", methods={"GET"}) + */ + public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter) + { + $query = new CustomerQuery(); + $query->setCurrentUser($this->getUser()); + + $form = $this->getToolbarForm($query); + $form->setData($query); + $form->submit($request->query->all(), false); + + if (!$form->isValid()) { + $query->resetByFormError($form->getErrors()); + } + + $entries = $this->repository->getCustomersForQuery($query); + + $spreadsheet = $exporter->export( + Customer::class, + $entries, + new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::EXPORT) + ); + $writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-customers'); + + return $writer->getFileResponse($spreadsheet); + } + /** * @param Customer $customer * @param Request $request diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 0654f477..a40f5540 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -19,6 +19,9 @@ use App\Entity\Rate; use App\Entity\Team; use App\Event\ProjectMetaDefinitionEvent; use App\Event\ProjectMetaDisplayEvent; +use App\Export\Spreadsheet\EntityWithMetaFieldsExporter; +use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter; +use App\Export\Spreadsheet\Writer\XlsxWriter; use App\Form\ProjectCommentForm; use App\Form\ProjectEditForm; use App\Form\ProjectRateForm; @@ -418,6 +421,34 @@ final class ProjectController extends AbstractController ]); } + /** + * @Route(path="/export", name="project_export", methods={"GET"}) + */ + public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter) + { + $query = new ProjectQuery(); + $query->setCurrentUser($this->getUser()); + + $form = $this->getToolbarForm($query); + $form->setData($query); + $form->submit($request->query->all(), false); + + if (!$form->isValid()) { + $query->resetByFormError($form->getErrors()); + } + + $entries = $this->repository->getProjectsForQuery($query); + + $spreadsheet = $exporter->export( + Project::class, + $entries, + new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT) + ); + $writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-projects'); + + return $writer->getFileResponse($spreadsheet); + } + /** * @param Project $project * @param Request $request diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 7d1465d0..0fa85ef9 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -12,6 +12,9 @@ namespace App\Controller; use App\Configuration\FormConfiguration; use App\Entity\User; use App\Event\UserPreferenceDisplayEvent; +use App\Export\Spreadsheet\UserExporter; +use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter; +use App\Export\Spreadsheet\Writer\XlsxWriter; use App\Form\Toolbar\UserToolbarForm; use App\Form\UserCreateType; use App\Repository\Query\UserQuery; @@ -65,7 +68,6 @@ final class UserController extends AbstractController /** * @Route(path="/", defaults={"page": 1}, name="admin_user", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_user_paginated", methods={"GET"}) - * @Security("is_granted('view_user')") */ public function indexAction($page, Request $request): Response { @@ -191,6 +193,34 @@ final class UserController extends AbstractController ); } + /** + * @Route(path="/export", name="user_export", methods={"GET"}) + * @Security("is_granted('view_user')") + */ + public function exportAction(Request $request, UserExporter $exporter) + { + $query = new UserQuery(); + $query->setCurrentUser($this->getUser()); + + $form = $this->getToolbarForm($query); + $form->setData($query); + $form->submit($request->query->all(), false); + + if (!$form->isValid()) { + $query->resetByFormError($form->getErrors()); + } + + $entries = $this->getRepository()->getUsersForQuery($query); + + $spreadsheet = $exporter->export( + $entries, + new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT) + ); + $writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-users'); + + return $writer->getFileResponse($spreadsheet); + } + protected function getToolbarForm(UserQuery $query): FormInterface { return $this->createForm(UserToolbarForm::class, $query, [ diff --git a/src/Entity/Activity.php b/src/Entity/Activity.php index 030cfff7..0eed73b4 100644 --- a/src/Entity/Activity.php +++ b/src/Entity/Activity.php @@ -9,6 +9,7 @@ namespace App\Entity; +use App\Export\Annotation as Exporter; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -44,6 +45,10 @@ use Symfony\Component\Validator\Constraints as Assert; * @Serializer\Groups({"Default"}) * } * ) + * + * @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "color", "visible", "comment"}) + * @Exporter\Expose("project", label="label.project", exp="object.getProject() === null ? null : object.getProject().getName()") + * @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array") */ class Activity implements EntityWithMetaFields { @@ -55,6 +60,8 @@ class Activity implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Default"}) * + * @Exporter\Expose(label="label.id", type="integer") + * * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="IDENTITY") @@ -75,6 +82,8 @@ class Activity implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Default"}) * + * @Exporter\Expose(label="label.name") + * * @ORM\Column(name="name", type="string", length=150, nullable=false) * @Assert\NotBlank() * @Assert\Length(min=2, max=150, allowEmptyString=false) @@ -88,6 +97,8 @@ class Activity implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Activity_Entity"}) * + * @Exporter\Expose(label="label.comment") + * * @ORM\Column(name="comment", type="text", nullable=true) */ private $comment; @@ -99,6 +110,8 @@ class Activity implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Default"}) * + * @Exporter\Expose(label="label.visible", type="boolean") + * * @ORM\Column(name="visible", type="boolean", nullable=false, options={"default": true}) * @Assert\NotNull() */ @@ -115,6 +128,8 @@ class Activity implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Activity_Entity"}) * + * @ Exporter\Expose(label="label.budget") + * * @ORM\Column(name="budget", type="float", nullable=false) * @Assert\NotNull() */ @@ -127,6 +142,8 @@ class Activity implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Activity_Entity"}) * + * @ Exporter\Expose(label="label.timeBudget", type="duration") + * * @ORM\Column(name="time_budget", type="integer", nullable=false) * @Assert\NotNull() */ diff --git a/src/Entity/ColorTrait.php b/src/Entity/ColorTrait.php index df247420..2a0f7ecc 100644 --- a/src/Entity/ColorTrait.php +++ b/src/Entity/ColorTrait.php @@ -9,6 +9,7 @@ namespace App\Entity; +use App\Export\Annotation as Exporter; use Doctrine\ORM\Mapping as ORM; use JMS\Serializer\Annotation as Serializer; use Symfony\Component\Validator\Constraints as Assert; @@ -23,6 +24,8 @@ trait ColorTrait * @Serializer\Expose() * @Serializer\Groups({"Default"}) * + * @Exporter\Expose(label="label.color") + * * @ORM\Column(name="color", type="string", length=7, nullable=true) * @Assert\Length(min=4, max=7) */ diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index fdbc6f62..1e98d868 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -9,6 +9,7 @@ namespace App\Entity; +use App\Export\Annotation as Exporter; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -25,6 +26,9 @@ use Symfony\Component\Validator\Constraints as Assert; * @ORM\Entity(repositoryClass="App\Repository\CustomerRepository") * * @Serializer\ExclusionPolicy("all") + * + * @Exporter\Order({"id", "name", "company", "number", "vatId", "address", "contact","email", "phone", "mobile", "fax", "homepage", "country", "currency", "timezone", "budget", "timeBudget", "color", "visible", "teams", "comment"}) + * @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array") */ class Customer implements EntityWithMetaFields { @@ -36,6 +40,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Default"}) * + * @Exporter\Expose(label="label.id", type="integer") + * * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="IDENTITY") @@ -47,6 +53,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Default"}) * + * @Exporter\Expose(label="label.name") + * * @ORM\Column(name="name", type="string", length=150, nullable=false) * @Assert\NotBlank() * @Assert\Length(min=2, max=150, allowEmptyString=false) @@ -58,6 +66,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @Exporter\Expose(label="label.number") + * * @ORM\Column(name="number", type="string", length=50, nullable=true) * @Assert\Length(max=50) */ @@ -68,6 +78,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @Exporter\Expose(label="label.comment") + * * @ORM\Column(name="comment", type="text", nullable=true) */ private $comment; @@ -77,6 +89,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Default"}) * + * @Exporter\Expose(label="label.visible", type="boolean") + * * @ORM\Column(name="visible", type="boolean", nullable=false) * @Assert\NotNull() */ @@ -87,6 +101,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @Exporter\Expose(label="label.company") + * * @ORM\Column(name="company", type="string", length=255, nullable=true) * @Assert\Length(max=255) */ @@ -97,6 +113,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @Exporter\Expose(label="label.vat_id") + * * @ORM\Column(name="vat_id", type="string", length=50, nullable=true) * @Assert\Length(max=50) */ @@ -107,6 +125,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @Exporter\Expose(label="label.contact") + * * @ORM\Column(name="contact", type="string", length=255, nullable=true) * @Assert\Length(max=255) */ @@ -117,6 +137,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @Exporter\Expose(label="label.address") + * * @ORM\Column(name="address", type="text", nullable=true) */ private $address; @@ -126,6 +148,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @Exporter\Expose(label="label.country") + * * @ORM\Column(name="country", type="string", length=2, nullable=false) * @Assert\NotBlank() * @Assert\Length(max=2) @@ -137,6 +161,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Customer"}) * + * @Exporter\Expose(label="label.currency") + * * @ORM\Column(name="currency", type="string", length=3, nullable=false) * @Assert\NotBlank() * @Assert\Length(max=3) @@ -148,6 +174,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @Exporter\Expose(label="label.phone") + * * @ORM\Column(name="phone", type="string", length=255, nullable=true) * @Assert\Length(max=255) */ @@ -158,6 +186,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @Exporter\Expose(label="label.fax") + * * @ORM\Column(name="fax", type="string", length=255, nullable=true) * @Assert\Length(max=255) */ @@ -168,6 +198,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @Exporter\Expose(label="label.mobile") + * * @ORM\Column(name="mobile", type="string", length=255, nullable=true) * @Assert\Length(max=255) */ @@ -182,6 +214,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @Exporter\Expose(label="label.email") + * * @ORM\Column(name="email", type="string", length=255, nullable=true) * @Assert\Length(max=254) */ @@ -192,6 +226,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @Exporter\Expose(label="label.homepage") + * * @ORM\Column(name="homepage", type="string", length=255, nullable=true) * @Assert\Length(max=255) */ @@ -206,6 +242,8 @@ class Customer implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @Exporter\Expose(label="label.timezone") + * * @ORM\Column(name="timezone", type="string", length=64, nullable=false) * @Assert\NotBlank() * @Assert\Length(max=64) @@ -216,25 +254,29 @@ class Customer implements EntityWithMetaFields use ColorTrait; /** - * The total monetary budget, will be zero if unconfigured. + * The total monetary budget, will be zero if not configured. * * @var float * * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @ Exporter\Expose(label="label.budget") + * * @ORM\Column(name="budget", type="float", nullable=false) * @Assert\NotNull() */ private $budget = 0.00; /** - * The time budget in seconds, will be be zero if unconfigured. + * The time budget in seconds, will be be zero if not configured. * * @var int * * @Serializer\Expose() * @Serializer\Groups({"Customer_Entity"}) * + * @ Exporter\Expose(label="label.timeBudget", type="duration") + * * @ORM\Column(name="time_budget", type="integer", nullable=false) * @Assert\NotNull() */ diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 5613a165..2bd029f2 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -9,6 +9,7 @@ namespace App\Entity; +use App\Export\Annotation as Exporter; use App\Validator\Constraints as Constraints; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -46,6 +47,10 @@ use Symfony\Component\Validator\Constraints as Assert; * @Serializer\Groups({"Project", "Team", "Not_Expanded"}) * } * ) + * + * @Exporter\Order({"id", "name", "customer", "orderNumber", "orderDate", "start", "end", "budget", "timeBudget", "color", "visible", "teams", "comment"}) + * @Exporter\Expose("customer", label="label.customer", exp="object.getCustomer() === null ? null : object.getCustomer().getName()") + * @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array") */ class Project implements EntityWithMetaFields { @@ -57,6 +62,8 @@ class Project implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Default"}) * + * @Exporter\Expose(label="label.id", type="integer") + * * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="IDENTITY") @@ -84,6 +91,8 @@ class Project implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Default"}) * + * @Exporter\Expose(label="label.name") + * * @ORM\Column(name="name", type="string", length=150, nullable=false) * @Assert\NotNull() * @Assert\Length(min=2, max=150, allowEmptyString=false) @@ -97,6 +106,8 @@ class Project implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Project_Entity"}) * + * @Exporter\Expose(label="label.orderNumber") + * * @ORM\Column(name="order_number", type="text", length=20, nullable=true) * @Assert\Length(max=20) */ @@ -108,6 +119,8 @@ class Project implements EntityWithMetaFields * @Serializer\Groups({"Project_Entity"}) * @Serializer\Type(name="DateTime") * + * @Exporter\Expose(label="label.orderDate", type="datetime") + * * @ORM\Column(name="order_date", type="datetime", nullable=true) */ private $orderDate; @@ -118,6 +131,8 @@ class Project implements EntityWithMetaFields * @Serializer\Groups({"Project"}) * @Serializer\Type(name="DateTime") * + * @Exporter\Expose(label="label.project_start", type="datetime") + * * @ORM\Column(name="start", type="datetime", nullable=true) */ private $start; @@ -128,6 +143,8 @@ class Project implements EntityWithMetaFields * @Serializer\Groups({"Project"}) * @Serializer\Type(name="DateTime") * + * @Exporter\Expose(label="label.project_end", type="datetime") + * * @ORM\Column(name="end", type="datetime", nullable=true) */ private $end; @@ -149,6 +166,8 @@ class Project implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Project_Entity"}) * + * @Exporter\Expose(label="label.comment") + * * @ORM\Column(name="comment", type="text", nullable=true) */ private $comment; @@ -158,6 +177,8 @@ class Project implements EntityWithMetaFields * @Serializer\Expose() * @Serializer\Groups({"Default"}) * + * @Exporter\Expose(label="label.visible", type="boolean") + * * @ORM\Column(name="visible", type="boolean", nullable=false) * @Assert\NotNull() */ @@ -167,25 +188,29 @@ class Project implements EntityWithMetaFields use ColorTrait; /** - * The total monetary budget, will be zero if unconfigured. + * The total monetary budget, will be zero if not configured. * * @var float * * @Serializer\Expose() * @Serializer\Groups({"Project_Entity"}) * + * @ Exporter\Expose(label="label.budget") + * * @ORM\Column(name="budget", type="float", nullable=false) * @Assert\NotNull() */ private $budget = 0.00; /** - * The time budget in seconds, will be be zero if unconfigured. + * The time budget in seconds, will be be zero if not configured. * * @var int * * @Serializer\Expose() * @Serializer\Groups({"Project_Entity"}) * + * @ Exporter\Expose(label="label.timeBudget", type="duration") + * * @ORM\Column(name="time_budget", type="integer", nullable=false) * @Assert\NotNull() */ diff --git a/src/Entity/User.php b/src/Entity/User.php index 72fb2674..bbb5211e 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -9,6 +9,7 @@ namespace App\Entity; +use App\Export\Annotation as Exporter; use App\Utils\StringHelper; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -49,6 +50,16 @@ use Symfony\Component\Validator\Constraints as Assert; * @Serializer\Groups({"User_Entity"}) * } * ) + * + * @Exporter\Order({"id", "username", "alias", "title", "email", "last_login", "language", "timezone", "active", "registeredAt", "roles", "teams"}) + * @Exporter\Expose("email", label="label.email", exp="object.getEmail()") + * @Exporter\Expose("username", label="label.username", exp="object.getUsername()") + * @Exporter\Expose("timezone", label="label.timezone", exp="object.getTimezone()") + * @Exporter\Expose("language", label="label.language", exp="object.getLanguage()") + * @Exporter\Expose("last_login", label="label.lastLogin", exp="object.getLastLogin()", type="datetime") + * @Exporter\Expose("roles", label="label.roles", exp="object.getRoles()", type="array") + * @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array") + * @Exporter\Expose("active", label="label.active", exp="object.isEnabled()", type="boolean") */ class User extends BaseUser implements UserInterface { @@ -73,6 +84,8 @@ class User extends BaseUser implements UserInterface * @Serializer\Expose() * @Serializer\Groups({"Default"}) * + * @Exporter\Expose(label="label.id", type="integer") + * * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(name="id", type="integer") @@ -86,6 +99,8 @@ class User extends BaseUser implements UserInterface * @Serializer\Expose() * @Serializer\Groups({"Default"}) * + * @Exporter\Expose(label="label.alias") + * * @ORM\Column(name="alias", type="string", length=60, nullable=true) * @Assert\Length(max=60) */ @@ -95,6 +110,8 @@ class User extends BaseUser implements UserInterface * * @var \DateTime * + * @Exporter\Expose(label="profile.registration_date", type="datetime") + * * @ORM\Column(name="registration_date", type="datetime", nullable=true) */ private $registeredAt; @@ -106,6 +123,8 @@ class User extends BaseUser implements UserInterface * @Serializer\Expose() * @Serializer\Groups({"User_Entity"}) * + * @Exporter\Expose(label="label.title") + * * @ORM\Column(name="title", type="string", length=50, nullable=true) * @Assert\Length(max=50) */ @@ -179,7 +198,7 @@ class User extends BaseUser implements UserInterface * This flag will be initialized in UserEnvironmentSubscriber. * * @var bool|null - * @internal has no database mapping. as the value is calculated from a permission + * @internal has no database mapping as the value is calculated from a permission */ private $isAllowedToSeeAllData = null; diff --git a/src/Event/AbstractMetaDisplayEvent.php b/src/Event/AbstractMetaDisplayEvent.php new file mode 100644 index 00000000..76dd5ce7 --- /dev/null +++ b/src/Event/AbstractMetaDisplayEvent.php @@ -0,0 +1,76 @@ +query = $query; + $this->location = $location; + } + + /** + * To filter where your meta-field will be displayed, use the query settings. + * + * @return BaseQuery + */ + public function getQuery(): BaseQuery + { + return $this->query; + } + + /** + * If you want to filter where your meta-field will be displayed, check the current location. + * + * @return string + */ + public function getLocation(): string + { + return $this->location; + } + + /** + * Add a new meta field that should be included. + * + * @param MetaTableTypeInterface $meta + */ + public function addField(MetaTableTypeInterface $meta) + { + $this->fields[] = $meta; + } + + /** + * Returns all meta-fields to be included. + * + * @return MetaTableTypeInterface[] + */ + public function getFields(): array + { + return $this->fields; + } +} diff --git a/src/Event/ActivityMetaDisplayEvent.php b/src/Event/ActivityMetaDisplayEvent.php index 530487ca..864664f2 100644 --- a/src/Event/ActivityMetaDisplayEvent.php +++ b/src/Event/ActivityMetaDisplayEvent.php @@ -9,68 +9,20 @@ namespace App\Event; -use App\Entity\MetaTableTypeInterface; use App\Repository\Query\ActivityQuery; -use App\Repository\Query\BaseQuery; -use Symfony\Contracts\EventDispatcher\Event; /** * Dynamically find possible meta fields for a activity query. + * + * @method ActivityQuery getQuery() */ -final class ActivityMetaDisplayEvent extends Event implements MetaDisplayEventInterface +final class ActivityMetaDisplayEvent extends AbstractMetaDisplayEvent { public const EXPORT = 'export'; public const ACTIVITY = 'activity'; - /** - * @var ActivityQuery - */ - private $query; - /** - * @var string - */ - private $location; - /** - * @var MetaTableTypeInterface[] - */ - private $fields = []; - public function __construct(ActivityQuery $query, string $location) { - $this->query = $query; - $this->location = $location; - } - - /** - * If you want to filter where your meta-field will be displayed, use the query settings. - * - * @return ActivityQuery - */ - public function getQuery(): BaseQuery - { - return $this->query; - } - - /** - * If you want to filter where your meta-field will be displayed, check the current location. - * - * @return string - */ - public function getLocation(): string - { - return $this->location; - } - - public function addField(MetaTableTypeInterface $meta) - { - $this->fields[] = $meta; - } - - /** - * @return MetaTableTypeInterface[] - */ - public function getFields(): array - { - return $this->fields; + parent::__construct($query, $location); } } diff --git a/src/Event/CustomerMetaDisplayEvent.php b/src/Event/CustomerMetaDisplayEvent.php index 79df6c55..8b718a98 100644 --- a/src/Event/CustomerMetaDisplayEvent.php +++ b/src/Event/CustomerMetaDisplayEvent.php @@ -9,68 +9,20 @@ namespace App\Event; -use App\Entity\MetaTableTypeInterface; -use App\Repository\Query\BaseQuery; use App\Repository\Query\CustomerQuery; -use Symfony\Contracts\EventDispatcher\Event; /** * Dynamically find possible meta fields for a customer query. + * + * @method CustomerQuery getQuery() */ -final class CustomerMetaDisplayEvent extends Event implements MetaDisplayEventInterface +final class CustomerMetaDisplayEvent extends AbstractMetaDisplayEvent { public const EXPORT = 'export'; public const CUSTOMER = 'customer'; - /** - * @var CustomerQuery - */ - private $query; - /** - * @var string - */ - private $location; - /** - * @var MetaTableTypeInterface[] - */ - private $fields = []; - public function __construct(CustomerQuery $query, string $location) { - $this->query = $query; - $this->location = $location; - } - - /** - * If you want to filter where your meta-field will be displayed, use the query settings. - * - * @return CustomerQuery - */ - public function getQuery(): BaseQuery - { - return $this->query; - } - - /** - * If you want to filter where your meta-field will be displayed, check the current location. - * - * @return string - */ - public function getLocation(): string - { - return $this->location; - } - - public function addField(MetaTableTypeInterface $meta) - { - $this->fields[] = $meta; - } - - /** - * @return MetaTableTypeInterface[] - */ - public function getFields(): array - { - return $this->fields; + parent::__construct($query, $location); } } diff --git a/src/Event/ProjectMetaDisplayEvent.php b/src/Event/ProjectMetaDisplayEvent.php index 340476ff..40a57ae0 100644 --- a/src/Event/ProjectMetaDisplayEvent.php +++ b/src/Event/ProjectMetaDisplayEvent.php @@ -9,68 +9,20 @@ namespace App\Event; -use App\Entity\MetaTableTypeInterface; -use App\Repository\Query\BaseQuery; use App\Repository\Query\ProjectQuery; -use Symfony\Contracts\EventDispatcher\Event; /** * Dynamically find possible meta fields for a project query. + * + * @method ProjectQuery getQuery() */ -final class ProjectMetaDisplayEvent extends Event implements MetaDisplayEventInterface +final class ProjectMetaDisplayEvent extends AbstractMetaDisplayEvent { public const EXPORT = 'export'; public const PROJECT = 'project'; - /** - * @var ProjectQuery - */ - private $query; - /** - * @var string - */ - private $location; - /** - * @var MetaTableTypeInterface[] - */ - private $fields = []; - public function __construct(ProjectQuery $query, string $location) { - $this->query = $query; - $this->location = $location; - } - - /** - * If you want to filter where your meta-field will be displayed, use the query settings. - * - * @return ProjectQuery - */ - public function getQuery(): BaseQuery - { - return $this->query; - } - - /** - * If you want to filter where your meta-field will be displayed, check the current location. - * - * @return string - */ - public function getLocation(): string - { - return $this->location; - } - - public function addField(MetaTableTypeInterface $meta) - { - $this->fields[] = $meta; - } - - /** - * @return MetaTableTypeInterface[] - */ - public function getFields(): array - { - return $this->fields; + parent::__construct($query, $location); } } diff --git a/src/Event/TimesheetMetaDisplayEvent.php b/src/Event/TimesheetMetaDisplayEvent.php index 88dfc8b0..66b24de3 100644 --- a/src/Event/TimesheetMetaDisplayEvent.php +++ b/src/Event/TimesheetMetaDisplayEvent.php @@ -9,15 +9,14 @@ namespace App\Event; -use App\Entity\MetaTableTypeInterface; -use App\Repository\Query\BaseQuery; use App\Repository\Query\TimesheetQuery; -use Symfony\Contracts\EventDispatcher\Event; /** * Dynamically find possible meta fields for a timesheet query. + * + * @method TimesheetQuery getQuery() */ -final class TimesheetMetaDisplayEvent extends Event implements MetaDisplayEventInterface +final class TimesheetMetaDisplayEvent extends AbstractMetaDisplayEvent { public const EXPORT = 'export'; public const TIMESHEET = 'timesheet'; @@ -25,55 +24,8 @@ final class TimesheetMetaDisplayEvent extends Event implements MetaDisplayEventI public const TIMESHEET_EXPORT = 'timesheet-export'; public const TEAM_TIMESHEET_EXPORT = 'team-timesheet-export'; - /** - * @var TimesheetQuery - */ - private $query; - /** - * @var string - */ - private $location; - /** - * @var MetaTableTypeInterface[] - */ - private $fields = []; - public function __construct(TimesheetQuery $query, string $location) { - $this->query = $query; - $this->location = $location; - } - - /** - * If you want to filter where your meta-field will be displayed, use the query settings. - * - * @return TimesheetQuery - */ - public function getQuery(): BaseQuery - { - return $this->query; - } - - /** - * If you want to filter where your meta-field will be displayed, check the current location. - * - * @return string - */ - public function getLocation(): string - { - return $this->location; - } - - public function addField(MetaTableTypeInterface $meta) - { - $this->fields[] = $meta; - } - - /** - * @return MetaTableTypeInterface[] - */ - public function getFields(): array - { - return $this->fields; + parent::__construct($query, $location); } } diff --git a/src/Export/Annotation/Expose.php b/src/Export/Annotation/Expose.php new file mode 100644 index 00000000..1a371809 --- /dev/null +++ b/src/Export/Annotation/Expose.php @@ -0,0 +1,57 @@ +name = $data['value']; + unset($data['value']); + } + + foreach ($data as $key => $value) { + if (!property_exists(self::class, $key)) { + throw new \InvalidArgumentException(sprintf('Unknown property "%s" on annotation "%s".', $key, self::class)); + } + $this->{$key} = $value; + } + } +} diff --git a/src/Export/Annotation/Order.php b/src/Export/Annotation/Order.php new file mode 100644 index 00000000..ca40df24 --- /dev/null +++ b/src/Export/Annotation/Order.php @@ -0,0 +1,32 @@ + + */ + public $order = []; + + public function __construct(array $data) + { + if (isset($data['value'])) { + $this->order = $data['value']; + unset($data['value']); + } + } +} diff --git a/src/Export/Spreadsheet/AnnotatedObjectExporter.php b/src/Export/Spreadsheet/AnnotatedObjectExporter.php new file mode 100644 index 00000000..1410da1e --- /dev/null +++ b/src/Export/Spreadsheet/AnnotatedObjectExporter.php @@ -0,0 +1,32 @@ +spreadsheetExporter = $spreadsheetExporter; + $this->annotationExtractor = $annotationExtractor; + } + + public function export(string $class, array $entries): Spreadsheet + { + $columns = $this->annotationExtractor->extract($class); + + return $this->spreadsheetExporter->export($columns, $entries); + } +} diff --git a/src/Export/Spreadsheet/CellFormatter/ArrayFormatter.php b/src/Export/Spreadsheet/CellFormatter/ArrayFormatter.php new file mode 100644 index 00000000..814c9950 --- /dev/null +++ b/src/Export/Spreadsheet/CellFormatter/ArrayFormatter.php @@ -0,0 +1,30 @@ +setCellValueByColumnAndRow($column, $row, ''); + + return; + } + + if (!\is_array($value)) { + throw new \InvalidArgumentException('Unsupported value given, only array is supported'); + } + + $sheet->setCellValueByColumnAndRow($column, $row, implode(';', $value)); + } +} diff --git a/src/Export/Spreadsheet/CellFormatter/BooleanFormatter.php b/src/Export/Spreadsheet/CellFormatter/BooleanFormatter.php new file mode 100644 index 00000000..2d47ec17 --- /dev/null +++ b/src/Export/Spreadsheet/CellFormatter/BooleanFormatter.php @@ -0,0 +1,30 @@ +setCellValueByColumnAndRow($column, $row, ''); + + return; + } + + if (!\is_bool($value)) { + throw new \InvalidArgumentException('Unsupported value given, only boolean is supported'); + } + + $sheet->setCellValueByColumnAndRow($column, $row, $value); + } +} diff --git a/src/Export/Spreadsheet/CellFormatter/CellFormatterInterface.php b/src/Export/Spreadsheet/CellFormatter/CellFormatterInterface.php new file mode 100644 index 00000000..120d619f --- /dev/null +++ b/src/Export/Spreadsheet/CellFormatter/CellFormatterInterface.php @@ -0,0 +1,24 @@ +setCellValueByColumnAndRow($column, $row, ''); + + return; + } + + if (!$value instanceof \DateTime) { + throw new \InvalidArgumentException('Unsupported value given, only DateTime is supported'); + } + + $sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($value)); + $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_YYYYMMDD2); + } +} diff --git a/src/Export/Spreadsheet/CellFormatter/DateTimeFormatter.php b/src/Export/Spreadsheet/CellFormatter/DateTimeFormatter.php new file mode 100644 index 00000000..671e2af3 --- /dev/null +++ b/src/Export/Spreadsheet/CellFormatter/DateTimeFormatter.php @@ -0,0 +1,34 @@ +setCellValueByColumnAndRow($column, $row, ''); + + return; + } + + if (!$value instanceof \DateTime) { + throw new \InvalidArgumentException('Unsupported value given, only DateTime is supported'); + } + + $sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($value)); + $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT); + } +} diff --git a/src/Export/Spreadsheet/CellFormatter/DurationFormatter.php b/src/Export/Spreadsheet/CellFormatter/DurationFormatter.php new file mode 100644 index 00000000..d8d77f83 --- /dev/null +++ b/src/Export/Spreadsheet/CellFormatter/DurationFormatter.php @@ -0,0 +1,31 @@ +setCellValueByColumnAndRow($column, $row, sprintf('=%s/86400', $value)); + $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DURATION_FORMAT); + } +} diff --git a/src/Export/Spreadsheet/CellFormatter/TimeFormatter.php b/src/Export/Spreadsheet/CellFormatter/TimeFormatter.php new file mode 100644 index 00000000..24e33496 --- /dev/null +++ b/src/Export/Spreadsheet/CellFormatter/TimeFormatter.php @@ -0,0 +1,34 @@ +setCellValueByColumnAndRow($column, $row, ''); + + return; + } + + if (!$value instanceof \DateTime) { + throw new \InvalidArgumentException('Unsupported value given, only DateTime is supported'); + } + + $sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($value)); + $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::TIME_FORMAT); + } +} diff --git a/src/Export/Spreadsheet/ColumnDefinition.php b/src/Export/Spreadsheet/ColumnDefinition.php new file mode 100644 index 00000000..d221ae5b --- /dev/null +++ b/src/Export/Spreadsheet/ColumnDefinition.php @@ -0,0 +1,39 @@ +label = $label; + $this->type = $type; + $this->accessor = $accessor; + } + + public function getLabel(): string + { + return $this->label; + } + + public function getType(): string + { + return $this->type; + } + + public function getAccessor(): callable + { + return $this->accessor; + } +} diff --git a/src/Export/Spreadsheet/EntityWithMetaFieldsExporter.php b/src/Export/Spreadsheet/EntityWithMetaFieldsExporter.php new file mode 100644 index 00000000..ba5a538c --- /dev/null +++ b/src/Export/Spreadsheet/EntityWithMetaFieldsExporter.php @@ -0,0 +1,36 @@ +exporter = $exporter; + $this->annotationExtractor = $annotationExtractor; + $this->metaFieldExtractor = $metaFieldExtractor; + } + + public function export(string $class, array $entries, MetaDisplayEventInterface $event): Spreadsheet + { + $columns = array_merge($this->annotationExtractor->extract($class), $this->metaFieldExtractor->extract($event)); + + return $this->exporter->export($columns, $entries); + } +} diff --git a/src/Export/Spreadsheet/Extractor/AnnotationExtractor.php b/src/Export/Spreadsheet/Extractor/AnnotationExtractor.php new file mode 100644 index 00000000..da475338 --- /dev/null +++ b/src/Export/Spreadsheet/Extractor/AnnotationExtractor.php @@ -0,0 +1,150 @@ +annotationReader = $annotationReader; + $this->expressionLanguage = new ExpressionLanguage(); + } + + /** + * @param string $value + * @return ColumnDefinition[] + * @throws ExtractorException + */ + public function extract($value): array + { + if (!\is_string($value)) { + throw new ExtractorException('AnnotationExtractor needs a class name (string) for work'); + } + + try { + $reflectionClass = new \ReflectionClass($value); + } catch (\ReflectionException $ex) { + throw new ExtractorException($ex->getMessage()); + } + + $columns = []; + + if (null !== ($definitions = $this->annotationReader->getClassAnnotations($reflectionClass))) { + foreach ($definitions as $definition) { + if ($definition instanceof Order) { + foreach ($definition->order as $columnName) { + $columns[$columnName] = null; + } + } + } + foreach ($definitions as $definition) { + if ($definition instanceof Expose) { + if (null === $definition->name) { + throw new ExtractorException(sprintf('@Expose needs a name attribute on class level hierarchy, check %s::class', $value)); + } + if (null === $definition->exp) { + throw new ExtractorException(sprintf('@Expose needs an expression attribute on class level hierarchy, check %s::class', $value)); + } + + $parsed = $this->expressionLanguage->parse($definition->exp, ['object']); + + $columns[$definition->name] = new ColumnDefinition( + $definition->label, + $definition->type, + function ($obj) use ($parsed) { + return $parsed->getNodes()->evaluate([], ['object' => $obj]); + } + ); + } + } + } + + foreach ($reflectionClass->getProperties() as $property) { + if (null !== ($definitions = $this->annotationReader->getPropertyAnnotations($property))) { + foreach ($definitions as $definition) { + if ($definition instanceof Expose) { + if (null !== $definition->exp) { + throw new ExtractorException(sprintf('@Expose only supports the expression attribute on class level hierarchy, check %s::$%s', $value, $property->getName())); + } + + $name = empty($definition->name) ? $property->getName() : $definition->name; + + $columns[$name] = new ColumnDefinition( + $definition->label, + $definition->type, + function ($obj) use ($property) { + if (!$property->isPublic()) { + $property->setAccessible(true); + } + + return $property->getValue($obj); + } + ); + } + } + } + } + + foreach ($reflectionClass->getMethods() as $method) { + if (null !== ($definitions = $this->annotationReader->getMethodAnnotations($method))) { + foreach ($definitions as $definition) { + if ($definition instanceof Expose) { + if (null !== $definition->exp) { + throw new ExtractorException(sprintf('@Expose only supports the expression attribute on class level hierarchy, check %s::%s()', $value, $method->getName())); + } + $name = empty($definition->name) ? $method->getName() : $definition->name; + + if (\count($method->getParameters()) > 0) { + throw new ExtractorException(sprintf('@Expose does not support method %s::%s(...), it has required parameters.', $value, $method->getName())); + } + + $columns[$name] = new ColumnDefinition( + $definition->label, + $definition->type, + function ($obj) use ($method) { + if (!$method->isPublic()) { + $method->setAccessible(true); + } + + return $method->invoke($obj); + } + ); + } + } + } + } + + foreach ($columns as $name => $definition) { + if (null === $definition) { + unset($columns[$name]); + } + } + + return array_values($columns); + } +} diff --git a/src/Export/Spreadsheet/Extractor/ExtractorException.php b/src/Export/Spreadsheet/Extractor/ExtractorException.php new file mode 100644 index 00000000..ef5a427b --- /dev/null +++ b/src/Export/Spreadsheet/Extractor/ExtractorException.php @@ -0,0 +1,14 @@ +eventDispatcher = $eventDispatcher; + } + + /** + * @param MetaDisplayEventInterface $value + * @return ColumnDefinition[] + * @throws ExtractorException + */ + public function extract($value): array + { + if (!($value instanceof MetaDisplayEventInterface)) { + throw new ExtractorException('MetaFieldExtractor needs a MetaDisplayEventInterface instance for work'); + } + + $columns = []; + + $this->eventDispatcher->dispatch($value); + + foreach ($value->getFields() as $field) { + if (!$field->isVisible()) { + continue; + } + + $columns[] = new ColumnDefinition( + $field->getLabel(), + 'string', + function (EntityWithMetaFields $entityWithMetaFields) use ($field) { + $meta = $entityWithMetaFields->getMetaField($field->getName()); + if (null === $meta) { + return null; + } + + return $meta->getValue(); + } + ); + } + + return $columns; + } +} diff --git a/src/Export/Spreadsheet/Extractor/UserPreferenceExtractor.php b/src/Export/Spreadsheet/Extractor/UserPreferenceExtractor.php new file mode 100644 index 00000000..8a01363b --- /dev/null +++ b/src/Export/Spreadsheet/Extractor/UserPreferenceExtractor.php @@ -0,0 +1,68 @@ +eventDispatcher = $eventDispatcher; + } + + /** + * @param UserPreferenceDisplayEvent $value + * @return ColumnDefinition[] + * @throws ExtractorException + */ + public function extract($value): array + { + if (!($value instanceof UserPreferenceDisplayEvent)) { + throw new ExtractorException('UserPreferenceExtractor needs a UserPreferenceDisplayEvent instance for work'); + } + + $columns = []; + + $this->eventDispatcher->dispatch($value); + + foreach ($value->getPreferences() as $field) { + if (!$field->isEnabled()) { + continue; + } + + $columns[] = new ColumnDefinition( + $field->getLabel(), + 'string', + function (User $user) use ($field) { + $meta = $user->getPreference($field->getName()); + if (null === $meta) { + return null; + } + + return $meta->getValue(); + } + ); + } + + return $columns; + } +} diff --git a/src/Export/Spreadsheet/SpreadsheetExporter.php b/src/Export/Spreadsheet/SpreadsheetExporter.php new file mode 100644 index 00000000..c4b8cec7 --- /dev/null +++ b/src/Export/Spreadsheet/SpreadsheetExporter.php @@ -0,0 +1,100 @@ +translator = $translator; + + $this->registerCellFormatter('datetime', new DateTimeFormatter()); + $this->registerCellFormatter('date', new DateFormatter()); + $this->registerCellFormatter('time', new TimeFormatter()); + $this->registerCellFormatter('duration', new DurationFormatter()); + $this->registerCellFormatter('boolean', new BooleanFormatter()); + $this->registerCellFormatter('array', new ArrayFormatter()); + } + + public function registerCellFormatter(string $type, CellFormatterInterface $formatter) + { + $this->formatter[$type] = $formatter; + } + + /** + * @param ColumnDefinition[] $columns + * @param array $entries + * @return Spreadsheet + * @throws \PhpOffice\PhpSpreadsheet\Exception + */ + public function export(array $columns, array $entries): Spreadsheet + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + // Set default row height to automatic, so we can specify wrap text columns later on + // without bloating the output file as we would need to store stylesheet info for every cell. + // LibreOffice is still not considering this flag, @see https://github.com/PHPOffice/PHPExcel/issues/588 + // with no solution implemented so nothing we can do about it there. + $sheet->getDefaultRowDimension()->setRowHeight(-1); + + $recordsHeaderColumn = 1; + $recordsHeaderRow = 1; + + foreach ($columns as $settings) { + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($settings->getLabel())); + } + + $entryHeaderRow = $recordsHeaderRow + 1; + + foreach ($entries as $entry) { + $entryHeaderColumn = 1; + + foreach ($columns as $settings) { + $value = \call_user_func($settings->getAccessor(), $entry); + + if (!\array_key_exists($settings->getType(), $this->formatter)) { + $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $value); + } else { + $formatter = $this->formatter[$settings->getType()]; + $formatter->setFormattedValue($sheet, $entryHeaderColumn, $entryHeaderRow, $value); + } + + $entryHeaderColumn++; + } + + $entryHeaderRow++; + } + + return $spreadsheet; + } +} diff --git a/src/Export/Spreadsheet/UserExporter.php b/src/Export/Spreadsheet/UserExporter.php new file mode 100644 index 00000000..6503118c --- /dev/null +++ b/src/Export/Spreadsheet/UserExporter.php @@ -0,0 +1,47 @@ +exporter = $exporter; + $this->annotationExtractor = $annotationExtractor; + $this->userPreferenceExtractor = $userPreferenceExtractor; + } + + /** + * @param User[] $entries + * @param UserPreferenceDisplayEvent $event + * @return Spreadsheet + * @throws Extractor\ExtractorException + * @throws \PhpOffice\PhpSpreadsheet\Exception + */ + public function export(array $entries, UserPreferenceDisplayEvent $event): Spreadsheet + { + $columns = array_merge( + $this->annotationExtractor->extract(User::class), + $this->userPreferenceExtractor->extract($event) + ); + + return $this->exporter->export($columns, $entries); + } +} diff --git a/src/Export/Spreadsheet/Writer/BinaryFileResponseWriter.php b/src/Export/Spreadsheet/Writer/BinaryFileResponseWriter.php new file mode 100644 index 00000000..0e5d4098 --- /dev/null +++ b/src/Export/Spreadsheet/Writer/BinaryFileResponseWriter.php @@ -0,0 +1,71 @@ +writer = $writer; + $this->prefix = urlencode($prefix); + } + + public function getFileExtension(): string + { + return $this->writer->getFileExtension(); + } + + public function getContentType(): string + { + return $this->writer->getContentType(); + } + + /** + * {@inheritdoc} + */ + public function save(Spreadsheet $spreadsheet, array $options = []): \SplFileInfo + { + return $this->writer->save($spreadsheet, $options); + } + + public function getFileResponse(Spreadsheet $spreadsheet, array $options = []): BinaryFileResponse + { + $file = $this->save($spreadsheet, $options); + + $filename = $this->prefix . '_' . (new \DateTime())->format('YmdHim') . '.' . $this->writer->getFileExtension(); + + $response = new BinaryFileResponse($file); + $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); + + $response->headers->set('Content-Type', $this->getContentType()); + $response->headers->set('Content-Disposition', $disposition); + $response->deleteFileAfterSend(true); + + return $response; + } +} diff --git a/src/Export/Spreadsheet/Writer/WriterInterface.php b/src/Export/Spreadsheet/Writer/WriterInterface.php new file mode 100644 index 00000000..56afe4f4 --- /dev/null +++ b/src/Export/Spreadsheet/Writer/WriterInterface.php @@ -0,0 +1,28 @@ + true, 'freeze' => null], $options); + + $filename = tempnam(sys_get_temp_dir(), 'kimai-export-xlsx'); + if (false === $filename) { + throw new \Exception('Could not open temporary file'); + } + + // Store expensive calculations for later + $sheet = $spreadsheet->getActiveSheet(); + $highestRow = $sheet->getHighestRow(); + $highestColumn = $sheet->getHighestColumn(); + + // Enable auto filter for header row + if (false !== $options['autofilter']) { + $sheet->setAutoFilter('A1:' . $highestColumn . '1'); + } + + // Freeze first row and date & time columns for easier navigation + if (!empty($options['freeze'])) { + $sheet->freezePane($options['freeze']); + } + + /** @var string $column */ + foreach (range('A', $highestColumn) as $column) { + // We default to a reasonable auto-width decided by the client, + // sadly ->getDefaultColumnDimension() is not supported so it needs + // to be specific about what column should be auto sized. + $col = $sheet->getColumnDimension($column); + + // If no other width is specified (which defaults to -1) + if ((int) $col->getWidth() === -1) { + $col->setAutoSize(true); + } + } + + // Text inside cells should be top left + $sheet + ->getStyle('A2:' . $highestColumn . $highestRow) + ->getAlignment() + ->setVertical(Alignment::VERTICAL_TOP) + ->setHorizontal(Alignment::HORIZONTAL_LEFT); + + $writer = IOFactory::createWriter($spreadsheet, 'Xlsx'); + $writer->save($filename); + + return new \SplFileInfo($filename); + } +} diff --git a/templates/activity/actions.html.twig b/templates/activity/actions.html.twig index 1061fe3d..fc917cf0 100644 --- a/templates/activity/actions.html.twig +++ b/templates/activity/actions.html.twig @@ -3,6 +3,8 @@ {% set actions = {'search': {'class': 'search-toggle visible-xs-inline'}, 'visibility': '#modal_activity_admin'} %} + {% set actions = actions|merge({'download': {'url': path('activity_export'), 'class': 'toolbar-action'}}) %} + {% if is_granted('create_activity') %} {% set actions = actions|merge({'create': path('admin_activity_create')}) %} {% endif %} diff --git a/templates/customer/actions.html.twig b/templates/customer/actions.html.twig index dd3018f6..b6a4e9c3 100644 --- a/templates/customer/actions.html.twig +++ b/templates/customer/actions.html.twig @@ -3,6 +3,8 @@ {% set actions = {'search': {'class': 'search-toggle visible-xs-inline'}, 'visibility': '#modal_customer_admin'} %} + {% set actions = actions|merge({'download': {'url': path('customer_export'), 'class': 'toolbar-action'}}) %} + {% if is_granted('create_customer') %} {% set actions = actions|merge({'create': path('admin_customer_create')}) %} {% endif %} diff --git a/templates/project/actions.html.twig b/templates/project/actions.html.twig index 7d552a2d..080f89e2 100644 --- a/templates/project/actions.html.twig +++ b/templates/project/actions.html.twig @@ -3,6 +3,8 @@ {% set actions = {'search': {'class': 'search-toggle visible-xs-inline'}, 'visibility': '#modal_project_admin'} %} + {% set actions = actions|merge({'download': {'url': path('project_export'), 'class': 'toolbar-action'}}) %} + {% if is_granted('create_project') %} {% set actions = actions|merge({'create': path('admin_project_create')}) %} {% endif %} diff --git a/templates/user/actions.html.twig b/templates/user/actions.html.twig index dea6d13b..38e594a7 100644 --- a/templates/user/actions.html.twig +++ b/templates/user/actions.html.twig @@ -9,6 +9,8 @@ {% set actions = actions|merge({'back': path('admin_user')}) %} {% endif %} + {% set actions = actions|merge({'download': {'url': path('user_export'), 'class': 'toolbar-action'}}) %} + {% if is_granted('role_permissions') %} {% set actions = actions|merge({'permissions': path('admin_user_permissions')}) %} {% endif %} diff --git a/templates/user/layout.html.twig b/templates/user/layout.html.twig index 5862c7a2..fe7aaca0 100644 --- a/templates/user/layout.html.twig +++ b/templates/user/layout.html.twig @@ -68,6 +68,10 @@