Skip to content

Commit

Permalink
allow to share an entire customer (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
vazaha-nl authored Aug 9, 2024
1 parent 56aa8ef commit 971e584
Show file tree
Hide file tree
Showing 17 changed files with 750 additions and 95 deletions.
51 changes: 26 additions & 25 deletions Controller/ManageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
use App\Repository\Query\BaseQuery;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use InvalidArgumentException;
use KimaiPlugin\SharedProjectTimesheetsBundle\Entity\SharedProjectTimesheet;
use KimaiPlugin\SharedProjectTimesheetsBundle\Form\SharedCustomerFormType;
use KimaiPlugin\SharedProjectTimesheetsBundle\Form\SharedProjectFormType;
use KimaiPlugin\SharedProjectTimesheetsBundle\Model\RecordMergeMode;
use KimaiPlugin\SharedProjectTimesheetsBundle\Repository\SharedProjectTimesheetRepository;
Expand Down Expand Up @@ -45,6 +47,7 @@ public function index(): Response
$table->setPagination($sharedProjects);
$table->setReloadEvents('kimai.sharedProject');

$table->addColumn('type', ['class' => 'alwaysVisible w-min', 'orderBy' => false]);
$table->addColumn('name', ['class' => 'alwaysVisible', 'orderBy' => false]);
$table->addColumn('url', ['class' => 'alwaysVisible', 'orderBy' => false]);
$table->addColumn('password', ['class' => 'd-none', 'orderBy' => false]);
Expand All @@ -70,11 +73,21 @@ public function index(): Response
#[Route(path: '/create', name: 'create_shared_project_timesheets', methods: ['GET', 'POST'])]
public function create(Request $request): Response
{
$type = $request->query->get('type');

if (!\in_array($type, [SharedProjectTimesheet::TYPE_CUSTOMER, SharedProjectTimesheet::TYPE_PROJECT])) {
throw new InvalidArgumentException('Invalid value for type');
}

$sharedProject = new SharedProjectTimesheet();

$form = $this->createForm(SharedProjectFormType::class, $sharedProject, [
$formClass = $type === SharedProjectTimesheet::TYPE_CUSTOMER ?
SharedCustomerFormType::class :
SharedProjectFormType::class;

$form = $this->createForm($formClass, $sharedProject, [
'method' => 'POST',
'action' => $this->generateUrl('create_shared_project_timesheets')
'action' => $this->generateUrl('create_shared_project_timesheets', ['type' => $type]),
]);
$form->handleRequest($request);

Expand All @@ -95,23 +108,20 @@ public function create(Request $request): Response
]);
}

#[Route(path: '/{projectId}/{shareKey}', name: 'update_shared_project_timesheets', methods: ['GET', 'POST'])]
public function update(string $projectId, string $shareKey, Request $request): Response
#[Route(path: '/{sharedProject}/{shareKey}', name: 'update_shared_project_timesheets', methods: ['GET', 'POST'])]
public function update(SharedProjectTimesheet $sharedProject, string $shareKey, Request $request): Response
{
if ($projectId == null || $shareKey == null) {
if ($sharedProject->getShareKey() !== $shareKey) {
throw $this->createNotFoundException('Project not found');
}

/** @var SharedProjectTimesheet $sharedProject */
$sharedProject = $this->shareProjectTimesheetRepository->findOneBy(['project' => $projectId, 'shareKey' => $shareKey]);
if ($sharedProject === null) {
throw $this->createNotFoundException('Given project not found');
}
$formClass = $sharedProject->isCustomerSharing() ?
SharedCustomerFormType::class :
SharedProjectFormType::class;

// Store data in temporary SharedProjectTimesheet object
$form = $this->createForm(SharedProjectFormType::class, $sharedProject, [
$form = $this->createForm($formClass, $sharedProject, [
'method' => 'POST',
'action' => $this->generateUrl('update_shared_project_timesheets', ['projectId' => $projectId, 'shareKey' => $shareKey])
'action' => $this->generateUrl('update_shared_project_timesheets', ['sharedProject' => $sharedProject->getId(), 'shareKey' => $shareKey])
]);
$form->handleRequest($request);

Expand All @@ -136,22 +146,13 @@ public function update(string $projectId, string $shareKey, Request $request): R
]);
}

#[Route(path: '/{projectId}/{shareKey}/remove', name: 'remove_shared_project_timesheets', methods: ['GET', 'POST'])]
public function remove(Request $request): Response
#[Route(path: '/{sharedProject}/{shareKey}/remove', name: 'remove_shared_project_timesheets', methods: ['GET', 'POST'])]
public function remove(SharedProjectTimesheet $sharedProject, string $shareKey): Response
{
$projectId = $request->get('projectId');
$shareKey = $request->get('shareKey');

if ($projectId == null || $shareKey == null) {
if ($sharedProject->getShareKey() !== $shareKey) {
throw $this->createNotFoundException('Project not found');
}

/** @var SharedProjectTimesheet $sharedProject */
$sharedProject = $this->shareProjectTimesheetRepository->findOneBy(['project' => $projectId, 'shareKey' => $shareKey]);
if (!$sharedProject || $sharedProject->getProject() === null || $sharedProject->getShareKey() === null) {
throw $this->createNotFoundException('Given project not found');
}

try {
$this->shareProjectTimesheetRepository->remove($sharedProject);
$this->flashSuccess('action.delete.success');
Expand Down
154 changes: 145 additions & 9 deletions Controller/ViewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
namespace KimaiPlugin\SharedProjectTimesheetsBundle\Controller;

use App\Controller\AbstractController;
use App\Customer\CustomerStatisticService;
use App\Entity\Customer;
use App\Entity\Project;
use App\Project\ProjectStatisticService;
use KimaiPlugin\SharedProjectTimesheetsBundle\Entity\SharedProjectTimesheet;
use KimaiPlugin\SharedProjectTimesheetsBundle\Repository\SharedProjectTimesheetRepository;
use KimaiPlugin\SharedProjectTimesheetsBundle\Service\ViewService;
use Symfony\Component\HttpFoundation\Request;
Expand All @@ -22,7 +25,7 @@
#[Route(path: '/auth/shared-project-timesheets')]
class ViewController extends AbstractController
{
#[Route(path: '/{id}/{shareKey}', name: 'view_shared_project_timesheets', methods: ['GET', 'POST'])]
#[Route(path: '/{project}/{shareKey}', name: 'view_shared_project_timesheets', methods: ['GET', 'POST'])]
public function indexAction(
Project $project,
string $shareKey,
Expand All @@ -33,9 +36,6 @@ public function indexAction(
): Response
{
$givenPassword = $request->get('spt-password');
$year = (int) $request->get('year', date('Y'));
$month = (int) $request->get('month', date('m'));
$detailsMode = $request->get('details', 'table');

// Get project.
$sharedProject = $sharedProjectTimesheetRepository->findByProjectAndShareKey(
Expand All @@ -55,6 +55,46 @@ public function indexAction(
]);
}

return $this->renderProjectView(
$sharedProject,
$sharedProject->getProject(),
$request,
$viewService,
$statisticsService,
);
}

#[Route(path: '/customer/{customer}/{shareKey}', name: 'view_shared_project_timesheets_customer', methods: ['GET', 'POST'])]
public function viewCustomerAction(
Customer $customer,
string $shareKey,
Request $request,
CustomerStatisticService $statisticsService,
ViewService $viewService,
SharedProjectTimesheetRepository $sharedProjectTimesheetRepository,
): Response
{
$givenPassword = $request->get('spt-password');
$year = (int) $request->get('year', date('Y'));
$month = (int) $request->get('month', date('m'));
$detailsMode = $request->get('details', 'table');
$sharedProject = $sharedProjectTimesheetRepository->findByCustomerAndShareKey(
$customer,
$shareKey
);

if ($sharedProject === null) {
throw $this->createNotFoundException('Project not found');
}

// Check access.
if (!$viewService->hasAccess($sharedProject, $givenPassword)) {
return $this->render('@SharedProjectTimesheets/view/auth.html.twig', [
'project' => $sharedProject->getCustomer(),
'invalidPassword' => $request->isMethod('POST') && $givenPassword !== null,
]);
}

// Get time records.
$timeRecords = $viewService->getTimeRecords($sharedProject, $year, $month);

Expand All @@ -66,28 +106,123 @@ public function indexAction(
$durationSum += $record->getDuration();
}

// Define currency.
$currency = $customer->getCurrency();

// Prepare stats for charts.
$annualChartVisible = $sharedProject->isAnnualChartVisible();
$monthlyChartVisible = $sharedProject->isMonthlyChartVisible();

$statsPerMonth = $annualChartVisible ? $viewService->getAnnualStats($sharedProject, $year) : null;
$statsPerDay = ($monthlyChartVisible && $detailsMode === 'chart')
? $viewService->getMonthlyStats($sharedProject, $year, $month) : null;

// we cannot call $this->getDateTimeFactory() as it throws a AccessDeniedException for anonymous users
$timezone = $customer->getTimezone() ?? date_default_timezone_get();
$date = new \DateTimeImmutable('now', new \DateTimeZone($timezone));
$stats = $statisticsService->getBudgetStatisticModel($customer, $date);
$projects = $sharedProjectTimesheetRepository->getProjects($sharedProject);

return $this->render('@SharedProjectTimesheets/view/customer.html.twig', [
'sharedProject' => $sharedProject,
'customer' => $customer,
'projects' => $projects,
'shareKey' => $shareKey,
'timeRecords' => $timeRecords,
'rateSum' => $rateSum,
'durationSum' => $durationSum,
'year' => $year,
'month' => $month,
'currency' => $currency,
'statsPerMonth' => $statsPerMonth,
'monthlyChartVisible' => $monthlyChartVisible,
'statsPerDay' => $statsPerDay,
'detailsMode' => $detailsMode,
'stats' => $stats,
]);
}

#[Route(path: '/customer/{customer}/{shareKey}/project/{project}', name: 'view_shared_project_timesheets_project', methods: ['GET', 'POST'])]
public function viewProjectAction(
Customer $customer,
string $shareKey,
Project $project,
Request $request,
ProjectStatisticService $statisticsService,
ViewService $viewService,
SharedProjectTimesheetRepository $sharedProjectTimesheetRepository,
): Response
{
$givenPassword = $request->get('spt-password');
$sharedProject = $sharedProjectTimesheetRepository->findByCustomerAndShareKey(
$customer,
$shareKey
);

if ($sharedProject === null) {
throw $this->createNotFoundException('Project not found');
}

// Check access.
if (!$viewService->hasAccess($sharedProject, $givenPassword)) {
return $this->render('@SharedProjectTimesheets/view/auth.html.twig', [
'project' => $sharedProject->getProject(),
'invalidPassword' => $request->isMethod('POST') && $givenPassword !== null,
]);
}

return $this->renderProjectView(
$sharedProject,
$project,
$request,
$viewService,
$statisticsService,
);
}

protected function renderProjectView(
SharedProjectTimesheet $sharedProject,
Project $project,
Request $request,
ViewService $viewService,
ProjectStatisticService $statisticsService,
): Response
{
$year = (int) $request->get('year', date('Y'));
$month = (int) $request->get('month', date('m'));
$detailsMode = $request->get('details', 'table');
$timeRecords = $viewService->getTimeRecords($sharedProject, $year, $month, $project);

// Calculate summary.
$rateSum = 0;
$durationSum = 0;
foreach($timeRecords as $record) {
$rateSum += $record->getRate();
$durationSum += $record->getDuration();
}

// Define currency.
$currency = 'EUR';
$customer = $sharedProject->getProject()?->getCustomer();
$customer = $project->getCustomer();

if ($customer !== null) {
$currency = $customer->getCurrency();
}

// Prepare stats for charts.
$annualChartVisible = $sharedProject->isAnnualChartVisible();
$monthlyChartVisible = $sharedProject->isMonthlyChartVisible();

$statsPerMonth = $annualChartVisible ? $viewService->getAnnualStats($sharedProject, $year) : null;
$statsPerMonth = $annualChartVisible ? $viewService->getAnnualStats($sharedProject, $year, $project) : null;
$statsPerDay = ($monthlyChartVisible && $detailsMode === 'chart')
? $viewService->getMonthlyStats($sharedProject, $year, $month) : null;
? $viewService->getMonthlyStats($sharedProject, $year, $month, $project) : null;

// we cannot call $this->getDateTimeFactory() as it throws a AccessDeniedException for anonymous users
$timezone = $project->getCustomer()->getTimezone() ?? date_default_timezone_get();
$date = new \DateTimeImmutable('now', new \DateTimeZone($timezone));

$stats = $statisticsService->getBudgetStatisticModel($project, $date);

return $this->render('@SharedProjectTimesheets/view/timesheet.html.twig', [
return $this->render('@SharedProjectTimesheets/view/project.html.twig', [
'sharedProject' => $sharedProject,
'timeRecords' => $timeRecords,
'rateSum' => $rateSum,
Expand All @@ -100,6 +235,7 @@ public function indexAction(
'statsPerDay' => $statsPerDay,
'detailsMode' => $detailsMode,
'stats' => $stats,
'project' => $project,
]);
}
}
46 changes: 44 additions & 2 deletions Entity/SharedProjectTimesheet.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,36 @@

namespace KimaiPlugin\SharedProjectTimesheetsBundle\Entity;

use App\Entity\Customer;
use App\Entity\Project;
use Doctrine\ORM\Mapping as ORM;
use KimaiPlugin\SharedProjectTimesheetsBundle\Model\RecordMergeMode;
use KimaiPlugin\SharedProjectTimesheetsBundle\Repository\SharedProjectTimesheetRepository;
use LogicException;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Table(name: 'kimai2_shared_project_timesheets')]
#[ORM\Index(columns: ['customer_id'])]
#[ORM\Index(columns: ['project_id'])]
#[ORM\Index(columns: ['share_key'])]
#[ORM\Index(columns: ['project_id', 'share_key'])]
#[ORM\Index(columns: ['customer_id', 'project_id', 'share_key'])]
#[ORM\Entity(repositoryClass: SharedProjectTimesheetRepository::class)]
class SharedProjectTimesheet
{
public const TYPE_PROJECT = 'project';
public const TYPE_CUSTOMER = 'customer';

#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
#[ORM\Column(name: 'id', type: 'integer')]
private ?int $id = null;

#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
private ?Customer $customer = null;

#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?Project $project = null;

#[ORM\Column(name: 'share_key', type: 'string', length: 20, nullable: false)]
Expand Down Expand Up @@ -78,6 +87,16 @@ public function setProject(Project $project): void
$this->project = $project;
}

public function getCustomer(): ?Customer
{
return $this->customer;
}

public function setCustomer(Customer $customer): void
{
$this->customer = $customer;
}

public function getShareKey(): ?string
{
return $this->shareKey;
Expand Down Expand Up @@ -172,4 +191,27 @@ public function setTimeBudgetStatsVisible(bool $timeBudgetStatsVisible): void
{
$this->timeBudgetStatsVisible = $timeBudgetStatsVisible;
}

public function getType(): string
{
if ($this->customer !== null && $this->project !== null) {
throw new LogicException('Invalid state: customer and project cannot be filled both');
}

if ($this->customer !== null) {
return static::TYPE_CUSTOMER;
}

return static::TYPE_PROJECT;
}

public function isCustomerSharing(): bool
{
return $this->getType() === static::TYPE_CUSTOMER;
}

public function isProjectSharing(): bool
{
return $this->getType() === static::TYPE_PROJECT;
}
}
Loading

0 comments on commit 971e584

Please sign in to comment.