Refactor work time management and billing summary
- Introduced a new `TasksController` to handle work time logic, moving it from the factory layer to the domain layer. - Created `WorkTimeRepository` to encapsulate data access for work time tasks, including methods to retrieve clients with unsettled tasks and calculate task times. - Updated the work time view to display a consolidated billing summary with improved UI elements and AJAX functionality for closing tasks. - Added new styles for the billing summary section in `style.scss`. - Implemented tests for the `TasksController` and `WorkTimeRepository` to ensure functionality and correctness. - Established a refactoring plan for future improvements and migrations within the CRM system.
This commit is contained in:
65
REFACTORING_PLAN.md
Normal file
65
REFACTORING_PLAN.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Plan Refaktoryzacji CRM PRO
|
||||||
|
|
||||||
|
## Cel
|
||||||
|
- Przenieść logikę biznesową i dostęp do danych z warstwy `factory` do `autoload/Domain/*`.
|
||||||
|
- Zachować kompatybilność wsteczną przez warstwy adapterów/fasad (`controls`, `factory`) podczas migracji.
|
||||||
|
- Kończyć każdy etap migracji testami regresyjnymi.
|
||||||
|
|
||||||
|
## Aktualny status
|
||||||
|
- Ostatnia aktualizacja: 2026-02-06
|
||||||
|
- Etap: 2 w toku (standaryzacja kontrolerów)
|
||||||
|
- Obszar Czas pracy: zmigrowany i ustabilizowany
|
||||||
|
|
||||||
|
## Etap 1: Tasks / Czas pracy (ZROBIONE)
|
||||||
|
- Dodano `autoload/Domain/Tasks/class.WorkTimeRepository.php`.
|
||||||
|
- `factory\Tasks::work_time_clients()` deleguje teraz do repozytorium Domain.
|
||||||
|
- Usunięto limit 3 miesięcy: repozytorium zwraca wszystkie istotne miesiące.
|
||||||
|
- Uwzględniono statusy zadań:
|
||||||
|
- `do rozliczenia` (`3`)
|
||||||
|
- `do sprawdzenia` (`1`)
|
||||||
|
- Usunięto zależność `\factory\Projects` z `WorkTimeRepository` (czas liczony bezpośrednio w repozytorium Domain).
|
||||||
|
- Dodano testy:
|
||||||
|
- `tests/Domain/Tasks/WorkTimeRepositoryTest.php`
|
||||||
|
- `tests/run.php`
|
||||||
|
|
||||||
|
## Etap 2: Standaryzacja kontrolerów (W TOKU)
|
||||||
|
- [x] Dodano `autoload/Controllers/class.TasksController.php`.
|
||||||
|
- [x] Przeniesiono akcję czasu pracy do nowego kontrolera: `TasksController::workTime()`.
|
||||||
|
- [x] Zostawiono adapter kompatybilności w `autoload/controls/class.Tasks.php`.
|
||||||
|
- [x] Oznaczono starą metodę `controls\Tasks::work_time()` jako deprecated.
|
||||||
|
- [x] Nowy kontroler korzysta bezpośrednio z Domain (`WorkTimeRepository`) zamiast factory.
|
||||||
|
- [x] Dodano test kontrolera:
|
||||||
|
- `tests/Controllers/TasksControllerTest.php`
|
||||||
|
- [ ] Zmigrować kolejne metody z `controls\Tasks`:
|
||||||
|
- `main_view()`
|
||||||
|
- `main_view_by_ajax()`
|
||||||
|
|
||||||
|
## Etap 3: Porządki w UI Czasu pracy (ZROBIONE)
|
||||||
|
- Nowy zbiorczy widok rozliczeń jest aktywny.
|
||||||
|
- Usunięto zduplikowany/stary blok z `templates/tasks/work-time.php`.
|
||||||
|
- Akcja „zamknij zadanie” działa przez AJAX bez pełnego przeładowania strony.
|
||||||
|
- Dodano ładniejsze potwierdzenie (`jquery-confirm`) z fallbackiem.
|
||||||
|
- Poprawiono hierarchię wizualną (Bootstrap + style pomocnicze).
|
||||||
|
- Przeniesiono style widoku z inline `<style>` do `layout/style.scss` pod `#billing-summary`.
|
||||||
|
|
||||||
|
## Etap 4: Migracja domeny finansów (NASTĘPNY)
|
||||||
|
- Wyodrębnić `Domain/Finances/FinancesRepository.php`.
|
||||||
|
- Migrować małymi krokami (po 1-2 metody):
|
||||||
|
- `operation_save`
|
||||||
|
- `operation_delete`
|
||||||
|
- metody listujące
|
||||||
|
- Po każdej grupie metod dodawać testy.
|
||||||
|
|
||||||
|
## Etap 5: Uporządkowanie struktury widoków (NASTĘPNY)
|
||||||
|
- Ustandaryzować mapowanie widoków pod `autoload/Views/*` (iteracyjnie).
|
||||||
|
- Zachować działanie starych ścieżek templatek do końca pełnej migracji.
|
||||||
|
|
||||||
|
## Polityka testów
|
||||||
|
- Każda zmigrowana metoda/funkcja musi mieć co najmniej jeden test automatyczny.
|
||||||
|
- `tests/run.php` pozostaje prostym, głównym entrypointem testów projektu.
|
||||||
|
- Dla zachowań UI (aktualizacje AJAX) dodać testy integracyjne/e2e, gdy pojawi się narzędzie testowe.
|
||||||
|
|
||||||
|
## Zasady migracji
|
||||||
|
- Bez zmian typu big-bang.
|
||||||
|
- Jeden ograniczony obszar funkcjonalny na commit.
|
||||||
|
- Najpierw kompatybilność, adaptery usuwać dopiero po pełnej migracji.
|
||||||
25
autoload/Controllers/class.TasksController.php
Normal file
25
autoload/Controllers/class.TasksController.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
namespace Controllers;
|
||||||
|
|
||||||
|
class TasksController
|
||||||
|
{
|
||||||
|
public static function workTime()
|
||||||
|
{
|
||||||
|
$work_time_repository = new \Domain\Tasks\WorkTimeRepository();
|
||||||
|
|
||||||
|
$view_model = self::workTimeViewModel(
|
||||||
|
$work_time_repository -> getClientsWithUnsettledTasks(),
|
||||||
|
\factory\Crm::settings()
|
||||||
|
);
|
||||||
|
|
||||||
|
return \Tpl::view( 'tasks/work-time', $view_model );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function workTimeViewModel( array $work_time_clients, array $settings )
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'work_time_clients' => $work_time_clients,
|
||||||
|
'settings' => $settings
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
125
autoload/Domain/Tasks/class.WorkTimeRepository.php
Normal file
125
autoload/Domain/Tasks/class.WorkTimeRepository.php
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
namespace Domain\Tasks;
|
||||||
|
|
||||||
|
class WorkTimeRepository
|
||||||
|
{
|
||||||
|
private const UNSETTLED_TASK_STATUSES = [ 1, 3 ]; // do sprawdzenia, do rozliczenia
|
||||||
|
private $mdb;
|
||||||
|
|
||||||
|
public function __construct( $mdb = null )
|
||||||
|
{
|
||||||
|
if ( $mdb )
|
||||||
|
$this -> mdb = $mdb;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
global $mdb;
|
||||||
|
$this -> mdb = $mdb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClientsWithUnsettledTasks()
|
||||||
|
{
|
||||||
|
$clients = $this -> mdb -> select( 'crm_client', '*', [ 'ORDER' => [ 'firm' => 'ASC' ] ] );
|
||||||
|
$work_time = [];
|
||||||
|
|
||||||
|
if ( !is_array( $clients ) or !count( $clients ) )
|
||||||
|
return $work_time;
|
||||||
|
|
||||||
|
foreach ( $clients as $client )
|
||||||
|
{
|
||||||
|
$task_rows = $this -> getClientTaskRows( (int)$client['id'] );
|
||||||
|
$client['tasks'] = self::buildClientTasksByMonth( $task_rows, function( $task_id, $month ) {
|
||||||
|
return (int)$this -> getTaskTotalTimeByMonth( $task_id, $month );
|
||||||
|
} );
|
||||||
|
|
||||||
|
if ( is_array( $client['tasks'] ) and count( $client['tasks'] ) )
|
||||||
|
krsort( $client['tasks'] );
|
||||||
|
|
||||||
|
$work_time[] = $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $work_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function buildClientTasksByMonth( array $task_rows, callable $task_time_provider )
|
||||||
|
{
|
||||||
|
$tasks = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ( $task_rows as $row )
|
||||||
|
{
|
||||||
|
if ( !isset( $row['month'] ) or !isset( $row['id'] ) )
|
||||||
|
continue;
|
||||||
|
|
||||||
|
$month = (string)$row['month'];
|
||||||
|
$task_id = (int)$row['id'];
|
||||||
|
$dedupe_key = $task_id . ':' . $month;
|
||||||
|
|
||||||
|
if ( isset( $seen[$dedupe_key] ) )
|
||||||
|
continue;
|
||||||
|
|
||||||
|
$seen[$dedupe_key] = true;
|
||||||
|
|
||||||
|
$tasks[ $month ][] = [
|
||||||
|
'id' => $task_id,
|
||||||
|
'name' => $row['name'],
|
||||||
|
'pay_rate' => $row['pay_rate'],
|
||||||
|
'time' => (int)call_user_func( $task_time_provider, $task_id, $month )
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getClientTaskRows( $client_id )
|
||||||
|
{
|
||||||
|
$statuses = implode( ',', self::UNSETTLED_TASK_STATUSES );
|
||||||
|
|
||||||
|
return $this -> mdb -> query(
|
||||||
|
'SELECT DISTINCT '
|
||||||
|
. 't.id, t.name, t.pay_rate, DATE_FORMAT(tw.date_end, \'%Y-%m\') AS month '
|
||||||
|
. 'FROM tasks AS t '
|
||||||
|
. 'INNER JOIN tasks_work AS tw ON t.id = tw.task_id '
|
||||||
|
. 'WHERE t.status IN (' . $statuses . ') '
|
||||||
|
. 'AND t.client_id = ' . (int)$client_id . ' '
|
||||||
|
. 'AND tw.deleted = 0 '
|
||||||
|
. 'AND tw.date_end IS NOT NULL '
|
||||||
|
. 'ORDER BY month DESC, t.date_add DESC'
|
||||||
|
) -> fetchAll( \PDO::FETCH_ASSOC );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getUnsettledTaskStatuses()
|
||||||
|
{
|
||||||
|
return self::UNSETTLED_TASK_STATUSES;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTaskTotalTimeByMonth( $task_id, $month )
|
||||||
|
{
|
||||||
|
$seconds = 0;
|
||||||
|
$date_from = $month . '-01 00:00:00';
|
||||||
|
$date_to = $month . '-' . date( 't', strtotime( $month ) ) . ' 23:59:59';
|
||||||
|
|
||||||
|
$rows = $this -> mdb -> select( 'tasks_work', [ 'date_start', 'date_end' ], [
|
||||||
|
'AND' => [
|
||||||
|
'deleted' => 0,
|
||||||
|
'task_id' => (int)$task_id,
|
||||||
|
'date_end[>=]' => $date_from,
|
||||||
|
'date_end[<=]' => $date_to
|
||||||
|
],
|
||||||
|
'ORDER' => [ 'id' => 'ASC' ]
|
||||||
|
] );
|
||||||
|
|
||||||
|
if ( !is_array( $rows ) or !count( $rows ) )
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
foreach ( $rows as $row )
|
||||||
|
{
|
||||||
|
if ( !$row['date_end'] )
|
||||||
|
$row['date_end'] = date( 'Y-m-d H:i:s' );
|
||||||
|
|
||||||
|
$seconds += strtotime( $row['date_end'] ) - strtotime( $row['date_start'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -490,12 +490,12 @@ class Tasks
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use \Controllers\TasksController::workTime() instead.
|
||||||
|
*/
|
||||||
static public function work_time()
|
static public function work_time()
|
||||||
{
|
{
|
||||||
return \Tpl::view( 'tasks/work-time', [
|
return \Controllers\TasksController::workTime();
|
||||||
'work_time_clients' => \factory\Tasks::work_time_clients(),
|
|
||||||
'settings' => \factory\Crm::settings()
|
|
||||||
] );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static public function change_task_work_date_start() {
|
static public function change_task_work_date_start() {
|
||||||
|
|||||||
@@ -383,38 +383,8 @@ class Tasks
|
|||||||
|
|
||||||
static public function work_time_clients()
|
static public function work_time_clients()
|
||||||
{
|
{
|
||||||
global $mdb;
|
$repository = new \Domain\Tasks\WorkTimeRepository();
|
||||||
|
return $repository -> getClientsWithUnsettledTasks();
|
||||||
$results = $mdb -> select( 'crm_client', '*', [ 'ORDER' => [ 'firm' => 'ASC' ] ] );
|
|
||||||
foreach ( $results as $row )
|
|
||||||
{
|
|
||||||
$results2 = $mdb -> query( 'SELECT DISTINCT(t.id), t.name, t.pay_rate FROM tasks AS t LEFT JOIN tasks_work AS tw ON t.id = tw.task_id WHERE status = 3 AND client_id = ' . $row['id'] . ' AND tw.date_end BETWEEN \'' . date( 'Y-m-01' ) . ' 00:00:00\' AND \'' . date( 'Y-m-' ) . date( 't' ) . ' 00:00:00\' ORDER BY t.date_add DESC' ) -> fetchAll( \PDO::FETCH_ASSOC );
|
|
||||||
foreach ( $results2 as $row2 )
|
|
||||||
{
|
|
||||||
|
|
||||||
$row2['time' ] = \factory\Projects::task_total_time( $row2['id'], date( 'Y-m' ) );
|
|
||||||
$row['tasks'][ date( 'Y-m' )][] = $row2;
|
|
||||||
}
|
|
||||||
|
|
||||||
$results2 = $mdb -> query( 'SELECT DISTINCT(t.id), t.name, t.pay_rate FROM tasks AS t LEFT JOIN tasks_work AS tw ON t.id = tw.task_id WHERE status = 3 AND client_id = ' . $row['id'] . ' AND tw.date_end BETWEEN \'' . date( 'Y-m-01', strtotime( '-1 months', time() ) ) . ' 00:00:00\' AND \'' . date( 'Y-m-', strtotime( '-1 months', time() ) ) . date( 't', strtotime( '-1 months', time() ) ) . ' 00:00:00\' ORDER BY t.date_add DESC' ) -> fetchAll( \PDO::FETCH_ASSOC );
|
|
||||||
foreach ( $results2 as $row2 )
|
|
||||||
{
|
|
||||||
|
|
||||||
$row2['time' ] = \factory\Projects::task_total_time( $row2['id'], date( 'Y-m', strtotime( '-1 months', time() ) ) );
|
|
||||||
$row['tasks'][ date( 'Y-m', strtotime( '-1 months', time() ) )][] = $row2;
|
|
||||||
}
|
|
||||||
|
|
||||||
$results2 = $mdb -> query( 'SELECT DISTINCT(t.id), t.name, t.pay_rate FROM tasks AS t LEFT JOIN tasks_work AS tw ON t.id = tw.task_id WHERE status = 3 AND client_id = ' . $row['id'] . ' AND tw.date_end BETWEEN \'' . date( 'Y-m-01', strtotime( '-2 months', time() ) ) . ' 00:00:00\' AND \'' . date( 'Y-m-', strtotime( '-2 months', time() ) ) . date( 't', strtotime( '-2 months', time() ) ) . ' 00:00:00\' ORDER BY t.date_add DESC' ) -> fetchAll( \PDO::FETCH_ASSOC );
|
|
||||||
foreach ( $results2 as $row2 )
|
|
||||||
{
|
|
||||||
|
|
||||||
$row2['time' ] = \factory\Projects::task_total_time( $row2['id'], date( 'Y-m', strtotime( '-2 months', time() ) ) );
|
|
||||||
$row['tasks'][ date( 'Y-m', strtotime( '-2 months', time() ) )][] = $row2;
|
|
||||||
}
|
|
||||||
|
|
||||||
$work_time[] = $row;
|
|
||||||
}
|
|
||||||
return $work_time;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// przy zmianach pamiętać o zadaniach z CRON
|
// przy zmianach pamiętać o zadaniach z CRON
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1538,3 +1538,45 @@ body>.top {
|
|||||||
max-width: calc(100% - 350px);
|
max-width: calc(100% - 350px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#billing-summary {
|
||||||
|
.card-header {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-kpi {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-kpi-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-kpi-value {
|
||||||
|
font-size: 21px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-details-row {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-client-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-table>thead>tr>th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-details-wrap {
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,157 +1,325 @@
|
|||||||
<?
|
<?
|
||||||
foreach ( $this -> work_time_clients as $client ):
|
$format_time = function( $seconds ) {
|
||||||
if ( !$client['tasks'] )
|
$seconds = (int)$seconds;
|
||||||
|
return sprintf( "%02d%s%02d%s%02d", floor( $seconds / 3600 ), ':', ( $seconds / 60 ) % 60, ':', $seconds % 60 );
|
||||||
|
};
|
||||||
|
|
||||||
|
$format_amount = function( $amount ) {
|
||||||
|
return number_format( (float)$amount, 2, '.', '' ) . ' zł';
|
||||||
|
};
|
||||||
|
|
||||||
|
$billing_clients = [];
|
||||||
|
$billing_total_amount = 0;
|
||||||
|
$billing_total_time = 0;
|
||||||
|
$billing_total_tasks = 0;
|
||||||
|
|
||||||
|
foreach ( $this -> work_time_clients as $client )
|
||||||
|
{
|
||||||
|
if ( !is_array( $client['tasks'] ) or !count( $client['tasks'] ) )
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
$pay_rate = null;
|
$months = $client['tasks'];
|
||||||
$pay_rate_summary = 0;
|
krsort( $months );
|
||||||
?>
|
|
||||||
<div class="card mb25">
|
$summary = [
|
||||||
<div class="card-header" id="<?= $client['firm'];?>"><?= $client['firm'];?></div>
|
'firm' => $client['firm'],
|
||||||
<div class="card-body">
|
'tasks_count' => 0,
|
||||||
<? if ( $client['tasks'][date( 'Y-m' )] ):?>
|
'time' => 0,
|
||||||
<? $time = 0;?>
|
'amount' => 0,
|
||||||
<table class="table table-sm">
|
'rows' => []
|
||||||
<tr>
|
];
|
||||||
<th colspan="4"><?= date( 'Y-m' );?></th>
|
|
||||||
</tr>
|
foreach ( $months as $month => $tasks )
|
||||||
<? foreach ( $client['tasks'][date( 'Y-m' )] as $task ):?>
|
{
|
||||||
<? $time += $task['time'];?>
|
if ( !is_array( $tasks ) or !count( $tasks ) )
|
||||||
<tr>
|
continue;
|
||||||
<td><?= $task['name'];?></td>
|
|
||||||
<td style="width: 100px; text-align: center;"><?= sprintf( "%02d%s%02d%s%02d", floor( $task['time'] / 3600 ), ':', ( $task['time'] / 60) % 60, ':', $task['time'] % 60 );?></td>
|
foreach ( $tasks as $task )
|
||||||
<td class="text-right" style="width: 100px;">
|
{
|
||||||
<?
|
$task_time = isset( $task['time'] ) ? (int)$task['time'] : 0;
|
||||||
if ( $task['pay_rate'] )
|
|
||||||
{
|
if ( isset( $task['pay_rate'] ) and $task['pay_rate'] !== null and $task['pay_rate'] !== '' )
|
||||||
echo $task['pay_rate'] . ' zł';
|
$task_amount = (float)$task['pay_rate'];
|
||||||
$pay_rate_summary += $task['pay_rate'];
|
else
|
||||||
}
|
$task_amount = (float)$this -> settings['hourly_rate'] * ( $task_time / 3600 );
|
||||||
else
|
|
||||||
{
|
$summary['tasks_count']++;
|
||||||
// calculater pay rate for task ( hourly rate * time spend on task )
|
$summary['time'] += $task_time;
|
||||||
$pay_rate = number_format( $this -> settings['hourly_rate'] * ( $task['time'] / 3600 ), 2, '.', '' );
|
$summary['amount'] += $task_amount;
|
||||||
$pay_rate_summary += $pay_rate;
|
$summary['rows'][] = [
|
||||||
echo $pay_rate . ' zł';
|
'month' => $month,
|
||||||
}
|
'id' => $task['id'],
|
||||||
?>
|
'name' => $task['name'],
|
||||||
</td>
|
'time' => $task_time,
|
||||||
<td class="text-center" style="width: 200px;">
|
'amount' => $task_amount
|
||||||
<a href="#" class="close-task" task-id="<?= $task['id'];?>" client="<?= $client['firm'];?>">zamknij zadanie</a>
|
];
|
||||||
</td>
|
}
|
||||||
</tr>
|
}
|
||||||
<? endforeach;?>
|
|
||||||
<tr>
|
if ( !$summary['tasks_count'] )
|
||||||
<td></td>
|
continue;
|
||||||
<td class="bold text-center"><?= sprintf( "%02d%s%02d%s%02d", floor( $time / 3600 ), ':', ( $time / 60) % 60, ':', $time % 60 );?></td>
|
|
||||||
<td class="bold text-right"><?= number_format( $pay_rate_summary, 2, '.', '' );?> zł</td>
|
$billing_total_tasks += $summary['tasks_count'];
|
||||||
</tr>
|
$billing_total_time += $summary['time'];
|
||||||
</table>
|
$billing_total_amount += $summary['amount'];
|
||||||
<? endif;?>
|
$billing_clients[] = $summary;
|
||||||
<? if ( $client['tasks'][date( 'Y-m', strtotime( '-1 months', time() ) )] ):?>
|
}
|
||||||
<? $time = 0;?>
|
|
||||||
<table class="table table-sm">
|
usort( $billing_clients, function( $a, $b ) {
|
||||||
<tr>
|
if ( $a['amount'] == $b['amount'] )
|
||||||
<th colspan="4"><?= date( 'Y-m', strtotime( '-1 months', time() ) );?></th>
|
return strcmp( $a['firm'], $b['firm'] );
|
||||||
</tr>
|
|
||||||
<? foreach ( $client['tasks'][date( 'Y-m', strtotime( '-1 months', time() ) )] as $task ):?>
|
return $a['amount'] < $b['amount'] ? 1 : -1;
|
||||||
<? $time += $task['time'];?>
|
} );
|
||||||
<tr>
|
?>
|
||||||
<td><?= $task['name'];?></td>
|
|
||||||
<td style="width: 100px; text-align: center;"><?= sprintf( "%02d%s%02d%s%02d", floor( $task['time'] / 3600 ), ':', ( $task['time'] / 60) % 60, ':', $task['time'] % 60 );?></td>
|
<div class="card mb25 border-primary" id="billing-summary">
|
||||||
<td class="text-right" style="width: 100px;">
|
<div class="card-header bg-primary text-white">Rozliczenia do zamknięcia (widok zbiorczy)</div>
|
||||||
<?
|
<div class="card-body">
|
||||||
if ( $task['pay_rate'] )
|
<div class="row mb15">
|
||||||
{
|
<div class="col-md-3 col-6 mb10">
|
||||||
echo $task['pay_rate'] . ' zł';
|
<div class="billing-kpi border-start border-4 border-primary">
|
||||||
$pay_rate_summary += $task['pay_rate'];
|
<div class="billing-kpi-label">Klienci</div>
|
||||||
}
|
<div class="billing-kpi-value text-primary" id="billing-kpi-clients"><?= count( $billing_clients );?></div>
|
||||||
else
|
</div>
|
||||||
{
|
</div>
|
||||||
// calculater pay rate for task ( hourly rate * time spend on task )
|
<div class="col-md-3 col-6 mb10">
|
||||||
$pay_rate = number_format( $this -> settings['hourly_rate'] * ( $task['time'] / 3600 ), 2, '.', '' );
|
<div class="billing-kpi border-start border-4 border-warning">
|
||||||
$pay_rate_summary += $pay_rate;
|
<div class="billing-kpi-label">Nierozliczone zadania</div>
|
||||||
echo $pay_rate . ' zł';
|
<div class="billing-kpi-value text-warning" id="billing-kpi-tasks"><?= $billing_total_tasks;?></div>
|
||||||
}
|
</div>
|
||||||
?>
|
</div>
|
||||||
</td>
|
<div class="col-md-3 col-6 mb10">
|
||||||
<td class="text-center" style="width: 200px;">
|
<div class="billing-kpi border-start border-4 border-info">
|
||||||
<a href="#" class="close-task" task-id="<?= $task['id'];?>" client="<?= $client['firm'];?>">zamknij zadanie</a>
|
<div class="billing-kpi-label">Suma czasu</div>
|
||||||
</td>
|
<div class="billing-kpi-value text-info" id="billing-kpi-time"><?= $format_time( $billing_total_time );?></div>
|
||||||
</tr>
|
</div>
|
||||||
<? endforeach;?>
|
</div>
|
||||||
<tr>
|
<div class="col-md-3 col-6 mb10">
|
||||||
<td></td>
|
<div class="billing-kpi border-start border-4 border-success">
|
||||||
<td class="bold text-center"><?= sprintf( "%02d%s%02d%s%02d", floor( $time / 3600 ), ':', ( $time / 60) % 60, ':', $time % 60 );?></td>
|
<div class="billing-kpi-label">Suma do zapłaty</div>
|
||||||
<td class="bold text-right"><?= number_format( $pay_rate_summary, 2, '.', '' );?> zł</td>
|
<div class="billing-kpi-value text-success" id="billing-kpi-amount"><?= $format_amount( $billing_total_amount );?></div>
|
||||||
</tr>
|
</div>
|
||||||
</table>
|
|
||||||
<? endif;?>
|
|
||||||
<? if ( $client['tasks'][date( 'Y-m', strtotime( '-2 months', time() ) )] ):?>
|
|
||||||
<? $time = 0;?>
|
|
||||||
<table class="table table-sm">
|
|
||||||
<tr>
|
|
||||||
<th colspan="4"><?= date( 'Y-m', strtotime( '-2 months', time() ) );?></th>
|
|
||||||
</tr>
|
|
||||||
<? foreach ( $client['tasks'][date( 'Y-m', strtotime( '-2 months', time() ) )] as $task ):?>
|
|
||||||
<? $time += $task['time'];?>
|
|
||||||
<tr>
|
|
||||||
<td><?= $task['name'];?></td>
|
|
||||||
<td style="width: 100px; text-align: center;"><?= sprintf( "%02d%s%02d%s%02d", floor( $task['time'] / 3600 ), ':', ( $task['time'] / 60) % 60, ':', $task['time'] % 60 );?></td>
|
|
||||||
<td class="text-right" style="width: 100px;">
|
|
||||||
<?
|
|
||||||
if ( $task['pay_rate'] )
|
|
||||||
{
|
|
||||||
echo $task['pay_rate'] . ' zł';
|
|
||||||
$pay_rate_summary += $task['pay_rate'];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// calculater pay rate for task ( hourly rate * time spend on task )
|
|
||||||
$pay_rate = number_format( $this -> settings['hourly_rate'] * ( $task['time'] / 3600 ), 2, '.', '' );
|
|
||||||
$pay_rate_summary += $pay_rate;
|
|
||||||
echo $pay_rate . ' zł';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
</td>
|
|
||||||
<td class="text-center" style="width: 200px;">
|
|
||||||
<a href="#" class="close-task" task-id="<?= $task['id'];?>" client="<?= $client['firm'];?>">zamknij zadanie</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<? endforeach;?>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td class="bold text-center"><?= sprintf( "%02d%s%02d%s%02d", floor( $time / 3600 ), ':', ( $time / 60) % 60, ':', $time % 60 );?></td>
|
|
||||||
<td class="bold text-right"><?= number_format( $pay_rate_summary, 2, '.', '' );?> zł</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<? endif;?>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?
|
|
||||||
endforeach;
|
<? if ( count( $billing_clients ) ):?>
|
||||||
?>
|
<div class="mb10" id="billing-filter-wrap">
|
||||||
|
<input type="text" class="form-control form-control-sm" id="billing-client-filter" placeholder="Filtruj klientów (nazwa firmy)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive" id="billing-table-wrap">
|
||||||
|
<table class="table table-sm table-hover billing-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Klient</th>
|
||||||
|
<th class="text-center" style="width: 130px;">Zadania</th>
|
||||||
|
<th class="text-center" style="width: 130px;">Czas</th>
|
||||||
|
<th class="text-right" style="width: 150px;">Kwota</th>
|
||||||
|
<th class="text-center" style="width: 150px;">Szczegóły</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<? foreach ( $billing_clients as $summary ):?>
|
||||||
|
<? $details_id = 'billing-details-' . md5( $summary['firm'] );?>
|
||||||
|
<tr class="billing-client-row" data-details-id="<?= $details_id;?>" data-tasks-count="<?= $summary['tasks_count'];?>" data-time="<?= $summary['time'];?>" data-amount="<?= number_format( (float)$summary['amount'], 2, '.', '' );?>">
|
||||||
|
<td class="billing-client-name"><a href="#<?= $summary['firm'];?>"><?= $summary['firm'];?></a></td>
|
||||||
|
<td class="text-center billing-client-tasks"><?= $summary['tasks_count'];?></td>
|
||||||
|
<td class="text-center billing-client-time"><?= $format_time( $summary['time'] );?></td>
|
||||||
|
<td class="text-right bold billing-client-amount"><?= $format_amount( $summary['amount'] );?></td>
|
||||||
|
<td class="text-center">
|
||||||
|
<a href="#" class="toggle-billing-details" data-target="<?= $details_id;?>">pokaż zadania</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr id="<?= $details_id;?>" class="billing-details-row">
|
||||||
|
<td colspan="5" class="billing-details-wrap">
|
||||||
|
<table class="table table-sm mb0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 90px;">Miesiąc</th>
|
||||||
|
<th>Zadanie</th>
|
||||||
|
<th class="text-center" style="width: 120px;">Czas</th>
|
||||||
|
<th class="text-right" style="width: 120px;">Kwota</th>
|
||||||
|
<th class="text-center" style="width: 160px;">Akcja</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<? foreach ( $summary['rows'] as $row ):?>
|
||||||
|
<tr class="billing-task-row" data-task-time="<?= (int)$row['time'];?>" data-task-amount="<?= number_format( (float)$row['amount'], 2, '.', '' );?>">
|
||||||
|
<td><?= $row['month'];?></td>
|
||||||
|
<td><?= $row['name'];?></td>
|
||||||
|
<td class="text-center"><?= $format_time( $row['time'] );?></td>
|
||||||
|
<td class="text-right"><?= $format_amount( $row['amount'] );?></td>
|
||||||
|
<td class="text-center">
|
||||||
|
<a href="#" class="close-task" task-id="<?= $row['id'];?>" client="<?= $summary['firm'];?>">zamknij zadanie</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<? endforeach;?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<? endforeach;?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<? else:?>
|
||||||
|
<div class="alert alert-success mb0" id="billing-empty-state">Brak zadań oczekujących na rozliczenie.</div>
|
||||||
|
<? endif;?>
|
||||||
|
<? if ( count( $billing_clients ) ):?>
|
||||||
|
<div class="alert alert-success mb0" id="billing-empty-state" style="display: none;">Brak zadań oczekujących na rozliczenie.</div>
|
||||||
|
<? endif;?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
$( function()
|
$( function()
|
||||||
{
|
{
|
||||||
|
function formatTime( seconds ) {
|
||||||
|
seconds = parseInt( seconds, 10 ) || 0;
|
||||||
|
var h = Math.floor( seconds / 3600 );
|
||||||
|
var m = Math.floor( ( seconds % 3600 ) / 60 );
|
||||||
|
var s = seconds % 60;
|
||||||
|
return String( h ).padStart( 2, '0' ) + ':' + String( m ).padStart( 2, '0' ) + ':' + String( s ).padStart( 2, '0' );
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount( amount ) {
|
||||||
|
amount = parseFloat( amount ) || 0;
|
||||||
|
return amount.toFixed( 2 ) + ' z\u0142';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshGlobalKpis() {
|
||||||
|
var clients = 0;
|
||||||
|
var tasks = 0;
|
||||||
|
var time = 0;
|
||||||
|
var amount = 0;
|
||||||
|
|
||||||
|
$( '.billing-client-row' ).each( function(){
|
||||||
|
clients++;
|
||||||
|
tasks += parseInt( $( this ).attr( 'data-tasks-count' ), 10 ) || 0;
|
||||||
|
time += parseInt( $( this ).attr( 'data-time' ), 10 ) || 0;
|
||||||
|
amount += parseFloat( $( this ).attr( 'data-amount' ) ) || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
$( '#billing-kpi-clients' ).text( clients );
|
||||||
|
$( '#billing-kpi-tasks' ).text( tasks );
|
||||||
|
$( '#billing-kpi-time' ).text( formatTime( time ) );
|
||||||
|
$( '#billing-kpi-amount' ).text( formatAmount( amount ) );
|
||||||
|
|
||||||
|
if ( clients === 0 )
|
||||||
|
{
|
||||||
|
$( '#billing-filter-wrap' ).hide();
|
||||||
|
$( '#billing-table-wrap' ).hide();
|
||||||
|
$( '#billing-empty-state' ).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCloseTask( onConfirm ) {
|
||||||
|
if ( typeof $.confirm === 'function' )
|
||||||
|
{
|
||||||
|
$.confirm({
|
||||||
|
title: 'Potwierdzenie',
|
||||||
|
content: 'Czy na pewno chcesz zamknąć to zadanie?',
|
||||||
|
type: 'orange',
|
||||||
|
buttons: {
|
||||||
|
cancel: {
|
||||||
|
text: 'Anuluj'
|
||||||
|
},
|
||||||
|
confirm: {
|
||||||
|
text: 'Zamknij zadanie',
|
||||||
|
btnClass: 'btn-orange',
|
||||||
|
action: function() {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( confirm( 'Czy na pewno chcesz zamknąć to zadanie?' ) )
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
$( 'body' ).on( 'click', '.toggle-billing-details', function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
var details_id = $( this ).attr( 'data-target' );
|
||||||
|
var details_row = $( '#' + details_id );
|
||||||
|
var is_visible = details_row.is( ':visible' );
|
||||||
|
|
||||||
|
details_row.toggle( !is_visible );
|
||||||
|
$( this ).text( is_visible ? 'poka\u017c zadania' : 'ukryj zadania' );
|
||||||
|
});
|
||||||
|
|
||||||
|
$( 'body' ).on( 'keyup', '#billing-client-filter', function(){
|
||||||
|
var term = $( this ).val().toLowerCase().trim();
|
||||||
|
|
||||||
|
$( '.billing-client-row' ).each( function(){
|
||||||
|
var row = $( this );
|
||||||
|
var details_id = row.attr( 'data-details-id' );
|
||||||
|
var client_name = row.find( '.billing-client-name' ).text().toLowerCase();
|
||||||
|
var is_match = !term || client_name.indexOf( term ) !== -1;
|
||||||
|
|
||||||
|
row.toggle( is_match );
|
||||||
|
if ( !is_match )
|
||||||
|
{
|
||||||
|
$( '#' + details_id ).hide();
|
||||||
|
row.find( '.toggle-billing-details' ).text( 'poka\u017c zadania' );
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$( 'body' ).on( 'click', '.close-task', function(e){
|
$( 'body' ).on( 'click', '.close-task', function(e){
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var task_id = $( this ).attr( 'task-id' );
|
|
||||||
var client = $( this ).attr( 'client' );
|
|
||||||
|
|
||||||
$.ajax({
|
var task_id = $( this ).attr( 'task-id' );
|
||||||
type: 'POST',
|
var button = $( this );
|
||||||
cache: false,
|
var task_row = button.closest( '.billing-task-row' );
|
||||||
url: '/tasks/task_change_status/',
|
var details_row = button.closest( 'tr.billing-details-row' );
|
||||||
data: {
|
var details_id = details_row.attr( 'id' );
|
||||||
task_id: task_id,
|
var summary_row = $( '.billing-client-row[data-details-id=\"' + details_id + '\"]' );
|
||||||
status: 2
|
|
||||||
},
|
confirmCloseTask( function() {
|
||||||
beforeSend: function() {},
|
$.ajax({
|
||||||
success: function( response ) {
|
type: 'POST',
|
||||||
document.location.href = '/tasks/work_time/#' + client;
|
cache: false,
|
||||||
location.reload();
|
url: '/tasks/task_change_status/',
|
||||||
}
|
data: {
|
||||||
|
task_id: task_id,
|
||||||
|
status: 2
|
||||||
|
},
|
||||||
|
beforeSend: function() {},
|
||||||
|
success: function( response ) {
|
||||||
|
task_row.remove();
|
||||||
|
|
||||||
|
var client_tasks = details_row.find( '.billing-task-row' );
|
||||||
|
var client_tasks_count = client_tasks.length;
|
||||||
|
var client_time = 0;
|
||||||
|
var client_amount = 0;
|
||||||
|
|
||||||
|
client_tasks.each( function(){
|
||||||
|
client_time += parseInt( $( this ).attr( 'data-task-time' ), 10 ) || 0;
|
||||||
|
client_amount += parseFloat( $( this ).attr( 'data-task-amount' ) ) || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if ( client_tasks_count === 0 )
|
||||||
|
{
|
||||||
|
summary_row.remove();
|
||||||
|
details_row.remove();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
summary_row.attr( 'data-tasks-count', client_tasks_count );
|
||||||
|
summary_row.attr( 'data-time', client_time );
|
||||||
|
summary_row.attr( 'data-amount', client_amount.toFixed( 2 ) );
|
||||||
|
summary_row.find( '.billing-client-tasks' ).text( client_tasks_count );
|
||||||
|
summary_row.find( '.billing-client-time' ).text( formatTime( client_time ) );
|
||||||
|
summary_row.find( '.billing-client-amount' ).text( formatAmount( client_amount ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshGlobalKpis();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
24
tests/Controllers/TasksControllerTest.php
Normal file
24
tests/Controllers/TasksControllerTest.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../autoload/Controllers/class.TasksController.php';
|
||||||
|
|
||||||
|
use Controllers\TasksController;
|
||||||
|
|
||||||
|
function assert_same( $expected, $actual, $message )
|
||||||
|
{
|
||||||
|
if ( $expected !== $actual )
|
||||||
|
throw new Exception( $message );
|
||||||
|
}
|
||||||
|
|
||||||
|
function run_tasks_controller_tests()
|
||||||
|
{
|
||||||
|
$clients = [
|
||||||
|
[ 'id' => 1, 'firm' => 'ACME', 'tasks' => [ '2026-01' => [] ] ]
|
||||||
|
];
|
||||||
|
$settings = [ 'hourly_rate' => 250 ];
|
||||||
|
|
||||||
|
$view_model = TasksController::workTimeViewModel( $clients, $settings );
|
||||||
|
|
||||||
|
assert_same( $clients, $view_model['work_time_clients'], 'Expected work_time_clients to be passed through.' );
|
||||||
|
assert_same( $settings, $view_model['settings'], 'Expected settings to be passed through.' );
|
||||||
|
}
|
||||||
40
tests/Domain/Tasks/WorkTimeRepositoryTest.php
Normal file
40
tests/Domain/Tasks/WorkTimeRepositoryTest.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../autoload/Domain/Tasks/class.WorkTimeRepository.php';
|
||||||
|
|
||||||
|
use Domain\Tasks\WorkTimeRepository;
|
||||||
|
|
||||||
|
function assert_true( $condition, $message )
|
||||||
|
{
|
||||||
|
if ( !$condition )
|
||||||
|
throw new Exception( $message );
|
||||||
|
}
|
||||||
|
|
||||||
|
function run_work_time_repository_tests()
|
||||||
|
{
|
||||||
|
$statuses = WorkTimeRepository::getUnsettledTaskStatuses();
|
||||||
|
assert_true( $statuses === [ 1, 3 ], 'Expected unsettled statuses to include do sprawdzenia and do rozliczenia.' );
|
||||||
|
|
||||||
|
$rows = [
|
||||||
|
[ 'id' => 11, 'name' => 'Task A', 'pay_rate' => '100.00', 'month' => '2026-01' ],
|
||||||
|
[ 'id' => 11, 'name' => 'Task A', 'pay_rate' => '100.00', 'month' => '2026-01' ], // duplicate
|
||||||
|
[ 'id' => 11, 'name' => 'Task A', 'pay_rate' => '100.00', 'month' => '2026-02' ],
|
||||||
|
[ 'id' => 12, 'name' => 'Task B', 'pay_rate' => null, 'month' => '2026-02' ],
|
||||||
|
[ 'id' => 13, 'name' => 'Task C', 'pay_rate' => null ] // invalid row, no month
|
||||||
|
];
|
||||||
|
|
||||||
|
$calls = [];
|
||||||
|
$tasks = WorkTimeRepository::buildClientTasksByMonth( $rows, function( $task_id, $month ) use ( &$calls ) {
|
||||||
|
$calls[] = $task_id . ':' . $month;
|
||||||
|
return $task_id * 10;
|
||||||
|
} );
|
||||||
|
|
||||||
|
assert_true( isset( $tasks['2026-01'] ), 'Expected month 2026-01 in result.' );
|
||||||
|
assert_true( isset( $tasks['2026-02'] ), 'Expected month 2026-02 in result.' );
|
||||||
|
assert_true( count( $tasks['2026-01'] ) === 1, 'Expected duplicate tasks to be deduplicated per month.' );
|
||||||
|
assert_true( count( $tasks['2026-02'] ) === 2, 'Expected two unique tasks in month 2026-02.' );
|
||||||
|
assert_true( $tasks['2026-01'][0]['time'] === 110, 'Expected task time to come from provider.' );
|
||||||
|
assert_true( $tasks['2026-02'][0]['time'] === 110, 'Expected month-specific provider call for the same task.' );
|
||||||
|
assert_true( $tasks['2026-02'][1]['time'] === 120, 'Expected provider value for second task.' );
|
||||||
|
assert_true( count( $calls ) === 3, 'Expected provider to be called once for each unique task+month pair.' );
|
||||||
|
}
|
||||||
33
tests/run.php
Normal file
33
tests/run.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/Domain/Tasks/WorkTimeRepositoryTest.php';
|
||||||
|
require_once __DIR__ . '/Controllers/TasksControllerTest.php';
|
||||||
|
|
||||||
|
$tests = [
|
||||||
|
'run_work_time_repository_tests',
|
||||||
|
'run_tasks_controller_tests'
|
||||||
|
];
|
||||||
|
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ( $tests as $test )
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$test();
|
||||||
|
echo "[OK] {$test}\n";
|
||||||
|
}
|
||||||
|
catch ( Throwable $e )
|
||||||
|
{
|
||||||
|
$failed++;
|
||||||
|
echo "[FAIL] {$test}: " . $e -> getMessage() . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $failed )
|
||||||
|
{
|
||||||
|
echo "\nFailed: {$failed}\n";
|
||||||
|
exit( 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nAll tests passed.\n";
|
||||||
Reference in New Issue
Block a user