Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gh3 allow to share an entire customer #7

Merged
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
cd016ce
db migration for customer sharing
Jul 29, 2024
276d405
set the project in timerecord model
Jul 31, 2024
ea2f3fb
renamed timesheet to project to reflect its purpose
Jul 31, 2024
acab803
template for timesheet for customer
Jul 31, 2024
b51fc1e
update entity: add customer relation + type getter
Jul 31, 2024
ec54c21
two separate forms, one for each type
Jul 31, 2024
50ed27d
add type column to index table
Jul 31, 2024
b193051
modify update/delete urls, we cannot use project_id anymore
Jul 31, 2024
f1c144d
correct links in action buttons
Jul 31, 2024
cf36c38
added submenu items to create button for each type
Jul 31, 2024
24669b1
update index table template with extra col + correct urls
Jul 31, 2024
30824fe
more changes to handle the new type
Jul 31, 2024
672fe83
url for shared customer timesheets + refactor project view logic
Jul 31, 2024
cc8591e
updates for stats per customer
Jul 31, 2024
f7ea6e3
fixes for phpstan errors
Jul 31, 2024
de8bb26
code style fixes
Jul 31, 2024
ecdc982
removed todo
Jul 31, 2024
63fd5e6
added null check
Jul 31, 2024
b1cc62a
bugfix: limit to project
Jul 31, 2024
98a8d08
bugfix: get correct currency
Jul 31, 2024
ccd0633
include stats for customer as well
Jul 31, 2024
cf4a4a0
partly fixed tests
Jul 31, 2024
3ecf416
whitespace
Jul 31, 2024
641ac1d
fix: rollback breaks if there are null values
Jul 31, 2024
00e075a
code style fixes
Jul 31, 2024
1bdffd3
pr feedback: remove null check, default value for project
Aug 9, 2024
f4c8eb0
pr feedback: col order + extra class
Aug 9, 2024
cf12140
pr feedback: extra functions to prevent use of constants outside class
Aug 9, 2024
be7808b
pr feedback: use icon
Aug 9, 2024
6ad5268
pr feedback: refactor getType(), return value is not nullable
Aug 9, 2024
f5effeb
pr feedback: use sharedProject i/o id in path params
Aug 9, 2024
44113b8
pr feedback: $shareKey is always a (non empty) string
Aug 9, 2024
961842e
pr feedback: use migration api for portability
Aug 9, 2024
b6f1ac6
regression fix: use correct param name
Aug 9, 2024
6578674
shorter heading
vazaha-nl Aug 9, 2024
d29d440
pr feedback: added missing closing tag
Aug 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -46,6 +48,7 @@ public function index(): Response
$table->setReloadEvents('kimai.sharedProject');

$table->addColumn('name', ['class' => 'alwaysVisible', 'orderBy' => false]);
$table->addColumn('type', ['class' => 'alwaysVisible', 'orderBy' => false]);
vazaha-nl marked this conversation as resolved.
Show resolved Hide resolved
$table->addColumn('url', ['class' => 'alwaysVisible', 'orderBy' => false]);
$table->addColumn('password', ['class' => 'd-none', 'orderBy' => false]);
$table->addColumn('record_merge_mode', ['class' => 'd-none text-center w-min', 'orderBy' => false, 'title' => 'shared_project_timesheets.manage.table.record_merge_mode']);
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: '/{id}/{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 ($shareKey == null || $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->getType() === SharedProjectTimesheet::TYPE_CUSTOMER ?
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', ['id' => $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: '/{id}/{shareKey}/remove', name: 'remove_shared_project_timesheets', methods: ['GET', 'POST'])]
vazaha-nl marked this conversation as resolved.
Show resolved Hide resolved
public function remove(SharedProjectTimesheet $sharedProject, string $shareKey): Response
{
$projectId = $request->get('projectId');
$shareKey = $request->get('shareKey');

if ($projectId == null || $shareKey == null) {
if ($shareKey == null || $sharedProject->getShareKey() !== $shareKey) {
vazaha-nl marked this conversation as resolved.
Show resolved Hide resolved
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
152 changes: 144 additions & 8 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 @@ -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,
]);
}
}
43 changes: 41 additions & 2 deletions Entity/SharedProjectTimesheet.php
vazaha-nl marked this conversation as resolved.
Show resolved Hide resolved
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,24 @@ public function setTimeBudgetStatsVisible(bool $timeBudgetStatsVisible): void
{
$this->timeBudgetStatsVisible = $timeBudgetStatsVisible;
}

public function getType(): ?string
{
$customer = $this->getCustomer();
$project = $this->getProject();

if (isset($customer, $project)) {
throw new LogicException('Invalid state: customer and project cannot be filled both');
}

if (isset($customer)) {
return static::TYPE_CUSTOMER;
}

if (isset($project)) {
return static::TYPE_PROJECT;
}

return null;
}
vazaha-nl marked this conversation as resolved.
Show resolved Hide resolved
}
Loading