This commit is contained in:
2026-04-02 12:00:38 +02:00
parent 46dae22a71
commit e743245cee
21 changed files with 2105 additions and 196 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
.scannerwork/
.env

View File

@@ -1,42 +1,43 @@
# Roadmap: crmPRO
# Roadmap: crmPRO
## Overview
Stabilizacja i poprawa jakości kodu crmPRO — identyfikacja i naprawa błędów, analiza jakości kodu przez SonarQube.
Stabilizacja i poprawa jakosci kodu crmPRO oraz rozwoj finansow o automatyczny import faktur z Fakturowni.
## Current Milestone
**v0.1 Stabilizacja i jakość kodu** (v0.1.0)
**v0.1 Stabilizacja i jakosc kodu + import finansow** (v0.1.0)
Status: In progress
Phases: 1 of 4 complete
Phases: 1 of 5 complete
## Phases
| Phase | Name | Plans | Status | Completed |
|-------|------|-------|--------|-----------|
| 1 | Konfiguracja SonarQube i baseline | 1/1 | Complete | 2026-03-15 |
| 2 | Naprawa błędów krytycznych | TBD | 🔵 Next | - |
| 3 | Naprawa błędów głównych | TBD | Not started | - |
| 1 | Konfiguracja SonarQube i baseline | 1/1 | Complete | 2026-03-15 |
| 2 | Naprawa bledow krytycznych | TBD | Next | - |
| 3 | Naprawa bledow glownych | TBD | Not started | - |
| 4 | Poprawa pokrycia testami | TBD | Not started | - |
| 5 | Import finansow z Fakturowni | 0/1 | Planning | - |
## Phase Details
### Phase 1: Konfiguracja SonarQube i baseline
### Phase 1: Konfiguracja SonarQube i baseline
**Goal:** Skonfigurować projekt w SonarQube, uruchomić pierwszy skan i uzyskać baseline jakości kodu
**Goal:** Skonfigurowac projekt w SonarQube, uruchomic pierwszy skan i uzyskac baseline jakosci kodu
**Depends on:** Nothing (first phase)
**Completed:** 2026-03-15
**Results:**
- sonar-project.properties skonfigurowany
- Pierwszy skan: 88 plików, 9356 LoC
- Pierwszy skan: 88 plikow, 9356 LoC
- Baseline: 58 bugs, 1649 code smells, 0% coverage, 5.6% duplikacji
- Reliability: D, Security: A, Maintainability: A
**Plans:**
- [x] 01-01: Konfiguracja SonarQube i analiza wyników pierwszego skanu
- [x] 01-01: Konfiguracja SonarQube i analiza wynikow pierwszego skanu
### Phase 2: Naprawa błędów krytycznych
### Phase 2: Naprawa bledow krytycznych
**Goal:** Naprawić wszystkie bugs i vulnerabilities o priorytecie Critical/Blocker
**Goal:** Naprawic wszystkie bugs i vulnerabilities o priorytecie Critical/Blocker
**Depends on:** Phase 1 (wyniki skanu)
**Research:** Unlikely
@@ -46,11 +47,11 @@ Phases: 1 of 4 complete
- Reskan po naprawach
**Plans:**
- [ ] 02-01: TBD (na podstawie wyników Phase 1)
- [ ] 02-01: TBD (na podstawie wynikow Phase 1)
### Phase 3: Naprawa błędów głównych
### Phase 3: Naprawa bledow glownych
**Goal:** Naprawić bugs o priorytecie Major i code smells wpływające na stabilność
**Goal:** Naprawic bugs o priorytecie Major i code smells wplywajace na stabilnosc
**Depends on:** Phase 2
**Research:** Unlikely
@@ -60,21 +61,36 @@ Phases: 1 of 4 complete
- Reskan po naprawach
**Plans:**
- [ ] 03-01: TBD (na podstawie wyników Phase 2)
- [ ] 03-01: TBD (na podstawie wynikow Phase 2)
### Phase 4: Poprawa pokrycia testami
**Goal:** Dodać testy dla kluczowych modułów zidentyfikowanych przez SonarQube
**Goal:** Dodac testy dla kluczowych modulow zidentyfikowanych przez SonarQube
**Depends on:** Phase 3
**Research:** Unlikely
**Scope:**
- Testy dla modułów o najniższym pokryciu
- Testy regresji dla naprawionych bugów
- Testy dla modulow o najnizszym pokryciu
- Testy regresji dla naprawionych bugow
**Plans:**
- [ ] 04-01: TBD (na podstawie wyników Phase 3)
- [ ] 04-01: TBD (na podstawie wynikow Phase 3)
### Phase 5: Import finansow z Fakturowni
**Goal:** Wdrozyc automatyczny import faktur przychodowych i kosztowych z Fakturowni do finansow crmPRO
**Depends on:** None (moze byc realizowane niezaleznie od prac SonarQube)
**Research:** Likely (szczegoly endpointow API i semantyka pol dokumentow)
**Scope:**
- Integracja API Fakturownia (dane z `.env` + data startu importu)
- Trwale mapowanie klientow i produktow/uslug do struktur finansowych CRM
- Import cykliczny przez cron z idempotencja i obsluga bledow
- Panel mapowania brakow w `/finances/main_view/`
**Plans:**
- [ ] 05-01: Integracja Fakturownia i automatyczny import do finansow
---
*Roadmap created: 2026-03-15*
*Last updated: 2026-03-15 — Phase 1 complete*
*Last updated: 2026-04-02 - Phase 5 planned*

View File

@@ -1,57 +1,38 @@
# Project State
# Project State
## Project Reference
See: .paul/PROJECT.md (updated 2026-03-15)
**Core value:** Użytkownicy mogą efektywnie zarządzać projektami, zadaniami i klientami w jednym systemie CRM
**Current focus:** Faza 2 — Naprawa błędów krytycznych
**Core value:** Uzytkownicy moga efektywnie zarzadzac projektami, zadaniami i klientami w jednym systemie CRM
**Current focus:** Faza 5 - Import finansow z Fakturowni
## Current Position
Milestone: v0.1 Stabilizacja i jakość kodu
Phase: 2 of 4 (Naprawa błędów krytycznych) — In Progress
Plan: 02-02 complete, 02-01 awaiting approval
Status: Loop closed for 02-02, ready for next plan
Last activity: 2026-03-15 — Completed .paul/phases/02-critical-bugs-fix/02-02-SUMMARY.md
Milestone: v0.1 Stabilizacja i jakosc kodu
Phase: 5 of 5 (Import finansow z Fakturowni) - Apply complete
Plan: 05-01 executed, awaiting unify
Status: APPLY complete, ready for UNIFY
Last activity: 2026-04-02 11:03 - Executed .paul/phases/05-finances-fakturownia-import/05-01-PLAN.md
Progress:
- Milestone: [██░░░░░░░░] 25%
- Phase 2: [█████░░░░░] 50% (1/2 plans complete)
- Milestone: [#######---] 35%
- Phase 5: [########--] 80%
## Loop Position
Current loop state:
```
PLAN ──▶ APPLY ──▶ UNIFY
[Loop complete for 02-02 — ready for next PLAN]
PLAN --> APPLY --> UNIFY
✓ ✓ [Apply completed, ready for reconciliation]
```
## Accumulated Context
### Decisions
- 2026-03-15: SonarQube URL = https://sonar.project-pro.pl (nie localhost)
- 2026-03-15: Skan automatyczny via sonar-scanner CLI + MCP do odczytu wyników
- 2026-03-15: Usunięto nawigację z layout-cron zamiast ukrywania CSS
- 2026-03-15: JSON.parse zamiast deprecated jQuery.parseJSON
### Deferred Issues
None yet.
### Blockers/Concerns
None.
### Git State
Last commit: e92c9fe
Branch: main
Feature branches merged: none
## Session Continuity
Last session: 2026-03-15
Stopped at: Plan 02-02 UNIFY complete
Next action: Approve and execute plan 02-01 (SonarQube bugs fix) — run /paul:apply .paul/phases/02-critical-bugs-fix/02-01-PLAN.md
Resume file: .paul/phases/02-critical-bugs-fix/02-02-SUMMARY.md
Last session: 2026-04-02 11:03
Stopped at: Apply finished for plan 05-01
Next action: Run $paul-unify .paul/phases/05-finances-fakturownia-import/05-01-PLAN.md
Resume file: .paul/phases/05-finances-fakturownia-import/05-01-PLAN.md
---
*STATE.md Updated after every significant action*
*STATE.md - Updated after every significant action*

View File

@@ -0,0 +1,223 @@
---
phase: 05-finances-fakturownia-import
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- cron.php
- autoload/class.Cron.php
- autoload/Controllers/FinancesController.php
- autoload/Domain/Finances/FinanceRepository.php
- autoload/Domain/Finances/FakturowniaApiClient.php
- autoload/Domain/Finances/FakturowniaImportRepository.php
- autoload/Domain/Finances/FakturowniaInvoiceImporter.php
- templates/finances/main-view.php
- templates/finances/fakturownia-import-panel.php
- .env.example
autonomous: true
---
<objective>
## Goal
Dodac automatyczny import faktur przychodowych i kosztowych z Fakturowni do finansow crmPRO, z mechanizmem uczenia mapowan klientow i produktow/uslug.
## Purpose
Zminimalizowac reczna prace w finansach: pierwsze faktury wymagaja mapowania, ale kolejne dokumenty z tymi samymi klientami i pozycjami maja importowac sie juz calkowicie automatycznie.
## Output
- Warstwa integracji API Fakturowni z konfiguracja przez `.env`
- Trwale mapowania klientow i produktow/uslug do struktur finansowych crmPRO
- Automatyczny import przez cron z idempotencja i data startu
- Panel w finansach do obslugi brakujacych mapowan
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@cron.php
@autoload/class.Cron.php
@autoload/Controllers/FinancesController.php
@autoload/Domain/Finances/FinanceRepository.php
@templates/finances/main-view.php
@config.php
</context>
<acceptance_criteria>
## AC-1: Konfiguracja API i data startu importu
```gherkin
Given w pliku .env ustawiono dane dostepowe Fakturowni oraz date startu importu
When cron uruchamia proces importu
Then importer laczy sie z API Fakturowni i pobiera tylko dokumenty od skonfigurowanej daty
And brakujaca konfiguracja zwraca czytelny blad bez przerwania calego crona
```
## AC-2: Import przychodow i kosztow z idempotencja
```gherkin
Given faktura przychodowa lub kosztowa istnieje w Fakturowni
When dokument ma komplet mapowan klienta i pozycji
Then powstaje operacja finansowa w crmPRO z poprawnym znakiem kwoty (przychod > 0, koszt < 0)
And ponowne uruchomienie importu nie tworzy duplikatu tej samej faktury
```
## AC-3: Mechanizm uczenia mapowan
```gherkin
Given dokument zawiera klienta lub pozycje bez mapowania
When importer przetwarza dokument
Then dokument nie jest importowany do finansow, a brakujace mapowania trafiaja na liste do recznego przypisania
And po zapisaniu mapowania kolejne faktury z tym klientem/pozycja importuja sie automatycznie
```
## AC-4: Obsluga mapowan w zakladce finansow
```gherkin
Given uzytkownik jest na /finances/main_view/
When otwiera panel importu Fakturownia
Then widzi brakujace mapowania klientow i produktow/uslug
And moze przypisac klienta CRM i kategorie finansowa
And zapis mapowania jest chroniony tokenem CSRF
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Zbudowac warstwe integracji Fakturownia + trwawe mapowania</name>
<files>
autoload/Domain/Finances/FakturowniaApiClient.php,
autoload/Domain/Finances/FakturowniaImportRepository.php,
autoload/Domain/Finances/FinanceRepository.php,
.env.example
</files>
<action>
1. Dodac loader `.env` (bez nowych bibliotek) i zdefiniowac klucze:
- `FAKTUROWNIA_API_DOMAIN`
- `FAKTUROWNIA_API_TOKEN`
- `FAKTUROWNIA_START_DATE`
- `FAKTUROWNIA_PAGE_LIMIT` (opcjonalnie)
2. Dodac klienta API Fakturowni z obsluga paginacji i timeoutow:
- pobieranie faktur przychodowych (np. endpoint invoices)
- pobieranie faktur kosztowych (np. endpoint costs/expenses)
3. Dodac repozytorium importu z tabelami tworzonymi przez `CREATE TABLE IF NOT EXISTS`:
- tabela mapowania klientow Fakturownia -> crm_client.id
- tabela mapowania pozycji (nazwa produktu/uslugi) -> finance_categories.id
- tabela stanu importu i idempotencji (external_id + typ dokumentu)
- tabela kolejki brakujacych mapowan do panelu finansow
4. Wszystkie zapytania SQL wykonywac przez medoo/query z parametrami bindowanymi (bez sklejania SQL z danymi wejsciowymi).
5. W `FinanceRepository` dodac metody pomocnicze do pobierania kategorii i klientow dla panelu mapowan.
Unikac: trzymania tokenu API w `config.php`, mapowan w sesji i importu bez kontroli duplikatow.
</action>
<verify>
- Odczyt `.env.example` potwierdza nowe klucze integracji.
- Odczyt nowych klas potwierdza osobne odpowiedzialnosci (API, repo mapowan, state importu).
- Test manualny: dwukrotne wywolanie zapisu importu dla tego samego external_id nie tworzy duplikatu.
</verify>
<done>AC-1 i AC-2 i AC-3 satisfied: konfiguracja, idempotencja i struktury mapowan gotowe</done>
</task>
<task type="auto">
<name>Task 2: Dodac importer dokumentow i podpiac go pod cron</name>
<files>
autoload/Domain/Finances/FakturowniaInvoiceImporter.php,
autoload/class.Cron.php,
cron.php
</files>
<action>
1. Dodac serwis importera, ktory:
- pobiera dokumenty od `FAKTUROWNIA_START_DATE`,
- scala dane klienta i pozycji,
- sprawdza mapowania klienta i pozycji,
- przy komplecie mapowan zapisuje operacje finansowe,
- przy brakach wpisuje rekordy do kolejki brakow mapowan i pomija import dokumentu.
2. Dodac polityke znaku kwoty:
- przychodowe -> dodatnie `amount`
- kosztowe -> ujemne `amount`
3. Wprowadzic idempotencje na poziomie zrodlowego `external_id` + typ dokumentu.
4. Rozszerzyc `Cron` i `cron.php` o krok `import_finances_from_fakturownia()`:
- uruchamiany przy kazdym cyklu crona,
- zwraca statusy `ok|empty|error` analogicznie do obecnych zadan,
- loguje krotki komunikat o liczbie zaimportowanych i pominietych dokumentow.
Unikac: przerywania calego crona przy pojedynczym bledzie faktury.
</action>
<verify>
- Suchy przebieg importera zwraca licznik imported/skipped/unmapped.
- Uruchomienie `php cron.php` wykonuje nowy krok i nie psuje istniejacych zadan cron.
- Powtorne uruchomienie dla tych samych danych nie dubluje wpisow w `finance_operations`.
</verify>
<done>AC-1 i AC-2 i AC-3 satisfied: cron importuje faktury cyklicznie i bez duplikatow</done>
</task>
<task type="auto">
<name>Task 3: Dodac panel mapowan Fakturowni w finansach i zapis mapowan</name>
<files>
autoload/Controllers/FinancesController.php,
templates/finances/main-view.php,
templates/finances/fakturownia-import-panel.php
</files>
<action>
1. Rozszerzyc `FinancesController` o akcje:
- pobranie brakujacych mapowan do widoku finansow,
- zapis mapowania klienta,
- zapis mapowania produktu/uslugi do kategorii,
- wszystkie zapisy z walidacja i `S::csrf_verify()`.
2. W `templates/finances/main-view.php` dolaczyc panel importu (partial) pokazujacy:
- liste nieprzypisanych klientow z Fakturowni + select klienta CRM,
- liste nieprzypisanych pozycji + select kategorii finansowej,
- status ostatniego importu (czas, liczba imported/skipped/unmapped).
3. W panelu uzyc escapingu danych wyjsciowych (np. `htmlspecialchars`) i komunikatow zgodnych z UI crmPRO.
Unikac: logiki biznesowej bezposrednio w widoku.
</action>
<verify>
- Wejscie na `/finances/main_view/` pokazuje panel mapowan bez regresji istniejacych statystyk.
- Zapis mapowania bez tokenu CSRF jest odrzucany.
- Po zapisaniu mapowania i ponownym imporcie dokument znika z listy brakow i tworzy operacje automatycznie.
</verify>
<done>AC-3 i AC-4 satisfied: mapowania sa obslugiwane z UI i kolejne faktury wpadaja automatycznie</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- modules niezwiązane z finansami (tasks, projects, wiki)
- mechanizm logowania i sesji poza niezbednym CSRF check
- istniejace endpointy email importera zadan
## SCOPE LIMITS
- Ten plan obejmuje tylko import i mapowania Fakturownia -> Finanse
- Bez refaktoryzacji calego modulu finansow
- Bez zmian w zewnetrznym harmonogramie systemowym (zakladamy, ze cron systemowy juz wywoluje `cron.php`)
- Bez automatycznego tworzenia nowych klientow CRM (tylko mapowanie do istniejacych)
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `.env.example` zawiera komplet nowych zmiennych Fakturowni
- [ ] Importer obsluguje dokumenty przychodowe i kosztowe
- [ ] Idempotencja blokuje duplikaty importu
- [ ] Brakujace mapowania sa kolejkowane i widoczne w finansach
- [ ] Zapis mapowan wymaga poprawnego tokenu CSRF
- [ ] `php cron.php` wykonuje import bez zatrzymywania pozostalych krokow
- [ ] All acceptance criteria met
</verification>
<success_criteria>
- Faktury przychodowe i kosztowe importuja sie automatycznie po uzupelnieniu mapowan
- Pierwszy import nie robi blednych przypisan: nieznane rekordy trafiaja do kolejki mapowania
- Kolejne dokumenty z tym samym klientem i pozycja sa przypisywane bez interwencji recznej
- Import uruchamia sie cyklicznie przez cron i jest bezpieczny na ponowne wykonanie
</success_criteria>
<output>
After completion, create `.paul/phases/05-finances-fakturownia-import/05-01-SUMMARY.md`
</output>

View File

@@ -681,8 +681,8 @@
},
"main_view.php": {
"type": "-",
"size": 47213,
"lmtime": 1773530856493,
"size": 49131,
"lmtime": 1774532986644,
"modified": false
},
"task_edit.php": {
@@ -693,9 +693,9 @@
},
"task_popup.php": {
"type": "-",
"size": 42366,
"size": 45012,
"lmtime": 1773530848546,
"modified": false
"modified": true
},
"task_single.php": {
"type": "-",
@@ -703,11 +703,17 @@
"lmtime": 1772276304856,
"modified": false
},
"task_work_logs_popup.php": {
"type": "-",
"size": 2629,
"lmtime": 0,
"modified": false
},
"work-time.php": {
"type": "-",
"size": 12763,
"size": 15966,
"lmtime": 1771236164971,
"modified": false
"modified": true
}
},
"users": {

47
__tmp_count.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
require 'autoload/class.Env.php';
Env::load('.env');
$domain = Env::get('FAKTUROWNIA_API_DOMAIN');
$token = Env::get('FAKTUROWNIA_API_TOKEN');
$start = Env::get('FAKTUROWNIA_START_DATE');
$base = strpos($domain, 'http') === 0 ? $domain : 'https://' . $domain;
function fetchAll($urlBase) {
$all = [];
for ($page = 1; $page <= 50; $page++) {
$url = $urlBase . '&page=' . $page . '&per_page=100';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
$body = curl_exec($ch);
curl_close($ch);
$arr = json_decode((string)$body, true);
if (!is_array($arr) || count($arr) === 0) break;
foreach ($arr as $row) $all[] = $row;
if (count($arr) < 100) break;
}
return $all;
}
function normalizeDate(array $doc): string {
$date = (string)($doc['issue_date'] ?? $doc['sell_date'] ?? $doc['created_at'] ?? '');
return substr($date, 0, 10);
}
$sales = fetchAll($base . '/invoices.json?api_token=' . urlencode($token));
$costs = fetchAll($base . '/invoices.json?api_token=' . urlencode($token) . '&income=no');
$sf = [];
foreach ($sales as $doc) {
$date = normalizeDate($doc);
if ($date && strtotime($date) >= strtotime($start) && (($doc['income'] ?? true) !== false)) $sf[] = $doc;
}
$cf = [];
foreach ($costs as $doc) {
$date = normalizeDate($doc);
if ($date && strtotime($date) >= strtotime($start)) $cf[] = $doc;
}
echo 'sales_from_start(issue_date_first)=' . count($sf) . PHP_EOL;
echo 'costs_from_start(issue_date_first)=' . count($cf) . PHP_EOL;

54
__tmp_diag.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
require 'autoload/class.Env.php';
Env::load('.env');
$domain = Env::get('FAKTUROWNIA_API_DOMAIN');
$token = Env::get('FAKTUROWNIA_API_TOKEN');
$start = Env::get('FAKTUROWNIA_START_DATE');
$base = strpos($domain, 'http') === 0 ? $domain : 'https://' . $domain;
function fetchAll($urlBase) {
$all = [];
for ($page = 1; $page <= 50; $page++) {
$url = $urlBase . '&page=' . $page . '&per_page=100';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
$body = curl_exec($ch);
curl_close($ch);
$arr = json_decode((string)$body, true);
if (!is_array($arr) || count($arr) === 0) break;
foreach ($arr as $row) $all[] = $row;
if (count($arr) < 100) break;
}
return $all;
}
$sales = fetchAll($base . '/invoices.json?api_token=' . urlencode($token));
$costs = fetchAll($base . '/invoices.json?api_token=' . urlencode($token) . '&income=no');
$sf = [];
foreach ($sales as $doc) {
$date = isset($doc['sell_date']) && $doc['sell_date'] ? $doc['sell_date'] : (isset($doc['issue_date']) ? $doc['issue_date'] : '');
if ($date && strtotime(substr($date,0,10)) >= strtotime($start)) $sf[] = $doc;
}
$cf = [];
foreach ($costs as $doc) {
$date = isset($doc['sell_date']) && $doc['sell_date'] ? $doc['sell_date'] : (isset($doc['issue_date']) ? $doc['issue_date'] : '');
if ($date && strtotime(substr($date,0,10)) >= strtotime($start)) $cf[] = $doc;
}
echo 'sales_all=' . count($sales) . PHP_EOL;
echo 'costs_all=' . count($costs) . PHP_EOL;
echo 'sales_from_start=' . count($sf) . PHP_EOL;
echo 'costs_from_start=' . count($cf) . PHP_EOL;
echo "-- sales_from_start --" . PHP_EOL;
foreach ($sf as $d) {
echo $d['id'] . ' | ' . ($d['number'] ?? '-') . ' | ' . ($d['sell_date'] ?? '-') . ' | ' . ($d['issue_date'] ?? '-') . PHP_EOL;
}
echo "-- costs_from_start --" . PHP_EOL;
foreach ($cf as $d) {
echo $d['id'] . ' | ' . ($d['number'] ?? '-') . ' | ' . ($d['sell_date'] ?? '-') . ' | ' . ($d['issue_date'] ?? '-') . PHP_EOL;
}

View File

@@ -8,6 +8,11 @@ class FinancesController
return new \Domain\Finances\FinanceRepository();
}
private static function importRepo()
{
return new \Domain\Finances\FakturowniaImportRepository();
}
private static function requireAuth()
{
global $user;
@@ -53,6 +58,8 @@ class FinancesController
return false;
$repo = self::repo();
$importRepo = self::importRepo();
$importRepo -> ensureTables();
if ( \S::get( 'tag-clear' ) )
unset( $_SESSION['finance-tag-id'] );
@@ -93,10 +100,121 @@ class FinancesController
'wallet_summary' => $repo -> walletSummary( $group_id ),
'wallet_summary_this_month' => $repo -> walletSummaryThisMonth( $group_id ),
'wallet_income_this_month' => $repo -> walletIncomeThisMonth( $group_id ),
'wallet_expenses_this_month' => $repo -> walletExpensesThisMonth( $group_id )
'wallet_expenses_this_month' => $repo -> walletExpensesThisMonth( $group_id ),
'fakturownia_pending_clients' => self::preparePendingRows( $importRepo -> pendingClientMappings() ),
'fakturownia_pending_items' => self::preparePendingRows( $importRepo -> pendingItemMappings() ),
'fakturownia_crm_clients' => $repo -> clientsList(),
'fakturownia_categories' => self::prepareCategoryOptions( $repo -> categoriesFlatList() ),
'fakturownia_last_summary' => self::lastImportSummary( $importRepo )
] );
}
private static function preparePendingRows( $rows )
{
$output = [];
if ( !is_array( $rows ) )
return $output;
foreach ( $rows as $row )
{
$payload = [];
if ( isset( $row['payload_json'] ) && $row['payload_json'] )
{
$decoded = json_decode( $row['payload_json'], true );
if ( is_array( $decoded ) )
$payload = $decoded;
}
$output[] = [
'external_key' => (string)$row['external_key'],
'external_name' => (string)$row['external_name'],
'hits' => (int)$row['hits'],
'last_seen_at' => (string)$row['last_seen_at'],
'payload' => $payload
];
}
return $output;
}
private static function lastImportSummary( $importRepo )
{
$raw = $importRepo -> getState( 'last_import_summary' );
if ( !$raw )
return null;
$decoded = json_decode( $raw, true );
return is_array( $decoded ) ? $decoded : null;
}
private static function prepareCategoryOptions( $categories )
{
$options = [];
if ( !is_array( $categories ) || empty( $categories ) )
return $options;
$byId = [];
foreach ( $categories as $category )
{
$id = isset( $category['id'] ) ? (int)$category['id'] : 0;
if ( $id <= 0 )
continue;
$byId[ $id ] = $category;
}
foreach ( $categories as $category )
{
$id = isset( $category['id'] ) ? (int)$category['id'] : 0;
if ( $id <= 0 )
continue;
$label = self::buildCategoryPathLabel( $id, $byId );
$options[] = [
'id' => $id,
'name' => $label,
'group_id' => isset( $category['group_id'] ) ? (int)$category['group_id'] : 0
];
}
usort( $options, function( $left, $right )
{
return strcmp( $left['name'], $right['name'] );
} );
return $options;
}
private static function buildCategoryPathLabel( $categoryId, $byId )
{
$parts = [];
$guard = 0;
$currentId = (int)$categoryId;
while ( $currentId > 0 && isset( $byId[ $currentId ] ) )
{
$category = $byId[ $currentId ];
$name = trim( (string)( $category['name'] ?? '' ) );
if ( $name !== '' )
array_unshift( $parts, $name );
$parentId = isset( $category['parent_id'] ) ? (int)$category['parent_id'] : 0;
if ( $parentId <= 0 || $parentId === $currentId )
break;
$currentId = $parentId;
$guard++;
if ( $guard > 20 )
break;
}
if ( empty( $parts ) )
return 'Kategoria #' . (int)$categoryId;
return implode( ' > ', $parts );
}
public static function operationEdit()
{
if ( !self::requireAuth() )
@@ -230,4 +348,70 @@ class FinancesController
'date_to' => $date_to
] );
}
public static function fakturowniaClientMappingSave()
{
if ( !self::requireAuth() )
return false;
if ( !\S::csrf_verify() )
{
\S::alert( 'Nieprawidlowy token bezpieczenstwa. Odswiez strone i sproboj ponownie.' );
header( 'Location: /finances/main_view/' );
exit;
}
$externalKey = trim( (string)\S::get( 'external_key' ) );
$externalName = trim( (string)\S::get( 'external_name' ) );
$crmClientId = (int)\S::get( 'crm_client_id' );
$repo = self::repo();
if ( $externalKey === '' || $externalName === '' || $crmClientId <= 0 || !$repo -> clientExists( $crmClientId ) )
{
\S::alert( 'Nie udalo sie zapisac mapowania klienta. Uzupelnij wszystkie pola.' );
header( 'Location: /finances/main_view/' );
exit;
}
$importRepo = self::importRepo();
$importRepo -> ensureTables();
$importRepo -> saveClientMapping( $externalKey, $externalName, $crmClientId );
\S::alert( 'Mapowanie klienta zostalo zapisane.' );
header( 'Location: /finances/main_view/' );
exit;
}
public static function fakturowniaItemMappingSave()
{
if ( !self::requireAuth() )
return false;
if ( !\S::csrf_verify() )
{
\S::alert( 'Nieprawidlowy token bezpieczenstwa. Odswiez strone i sproboj ponownie.' );
header( 'Location: /finances/main_view/' );
exit;
}
$externalKey = trim( (string)\S::get( 'external_key' ) );
$externalName = trim( (string)\S::get( 'external_name' ) );
$financeCategoryId = (int)\S::get( 'finance_category_id' );
$repo = self::repo();
if ( $externalKey === '' || $externalName === '' || $financeCategoryId <= 0 || !$repo -> categoryExists( $financeCategoryId ) )
{
\S::alert( 'Nie udalo sie zapisac mapowania pozycji. Uzupelnij wszystkie pola.' );
header( 'Location: /finances/main_view/' );
exit;
}
$importRepo = self::importRepo();
$importRepo -> ensureTables();
$importRepo -> saveItemMapping( $externalKey, $externalName, $financeCategoryId );
\S::alert( 'Mapowanie pozycji zostalo zapisane.' );
header( 'Location: /finances/main_view/' );
exit;
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace Domain\Finances;
class FakturowniaApiClient
{
private $baseUrl;
private $apiToken;
private $pageLimit;
private $timeout;
public function __construct( $baseUrl, $apiToken, $pageLimit = 100, $timeout = 20 )
{
$this -> baseUrl = rtrim( (string)$baseUrl, '/' );
$this -> apiToken = (string)$apiToken;
$this -> pageLimit = max( 1, (int)$pageLimit );
$this -> timeout = max( 5, (int)$timeout );
}
public function fetchSalesDocuments( $startDate, $page = 1 )
{
$query = [
'page' => (int)$page,
'per_page' => $this -> pageLimit
];
if ( $this -> canUseCurrentMonthPeriod( $startDate ) )
$query['period'] = 'this_month';
return $this -> requestList( '/invoices.json', $query );
}
public function fetchCostDocuments( $startDate, $page = 1 )
{
$queries = [
[
'page' => (int)$page,
'per_page' => $this -> pageLimit,
'income' => 'no'
]
];
if ( $this -> canUseCurrentMonthPeriod( $startDate ) )
$queries[0]['period'] = 'this_month';
$paths = [ '/costs.json', '/expenses.json', '/invoices.json' ];
foreach ( $paths as $path )
{
foreach ( $queries as $query )
{
$response = $this -> requestList( $path, $query, true );
if ( $response['ok'] )
return $response['data'];
}
}
throw new \RuntimeException( 'Nie udalo sie pobrac faktur kosztowych z API Fakturowni.' );
}
public function fetchInvoiceDetails( $invoiceId )
{
$invoiceId = (int)$invoiceId;
if ( $invoiceId <= 0 )
return null;
$result = $this -> request( '/invoices/' . $invoiceId . '.json', [] );
if ( $result['http_code'] >= 400 )
return null;
$data = json_decode( $result['body'], true );
return is_array( $data ) ? $data : null;
}
private function requestList( $path, $query, $softFail = false )
{
$result = $this -> request( $path, $query );
if ( $result['http_code'] >= 400 )
{
if ( $softFail )
return [ 'ok' => false, 'data' => [] ];
throw new \RuntimeException( 'Blad API Fakturowni: HTTP ' . $result['http_code'] . ' dla ' . $path );
}
$data = json_decode( $result['body'], true );
if ( !is_array( $data ) )
{
if ( $softFail )
return [ 'ok' => false, 'data' => [] ];
throw new \RuntimeException( 'API Fakturowni zwrocilo niepoprawny JSON.' );
}
$list = $this -> extractList( $data );
return $softFail ? [ 'ok' => true, 'data' => $list ] : $list;
}
private function request( $path, $query )
{
$query['api_token'] = $this -> apiToken;
$url = $this -> baseUrl . $path . '?' . http_build_query( $query );
$ch = curl_init( $url );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, $this -> timeout );
curl_setopt( $ch, CURLOPT_TIMEOUT, $this -> timeout );
curl_setopt( $ch, CURLOPT_HTTPHEADER, [
'Accept: application/json'
] );
$body = curl_exec( $ch );
$httpCode = (int)curl_getinfo( $ch, CURLINFO_HTTP_CODE );
$error = curl_error( $ch );
curl_close( $ch );
if ( $body === false )
throw new \RuntimeException( 'Blad polaczenia z API Fakturowni: ' . $error );
return [
'http_code' => $httpCode,
'body' => $body
];
}
private function extractList( $data )
{
if ( $this -> isList( $data ) )
return $data;
$keys = [ 'invoices', 'costs', 'expenses', 'data' ];
foreach ( $keys as $key )
{
if ( isset( $data[ $key ] ) && is_array( $data[ $key ] ) )
{
if ( $this -> isList( $data[ $key ] ) )
return $data[ $key ];
}
}
return [];
}
private function isList( $value )
{
if ( !is_array( $value ) )
return false;
if ( $value === [] )
return true;
return array_keys( $value ) === range( 0, count( $value ) - 1 );
}
private function canUseCurrentMonthPeriod( $startDate )
{
if ( !is_string( $startDate ) || !preg_match( '/^\d{4}-\d{2}-\d{2}$/', $startDate ) )
return false;
return $startDate === date( 'Y-m-01' );
}
}

View File

@@ -0,0 +1,318 @@
<?php
namespace Domain\Finances;
class FakturowniaImportRepository
{
private $mdb;
private $tablesReady = false;
public function __construct( $mdb = null )
{
if ( $mdb )
$this -> mdb = $mdb;
else
{
global $mdb;
$this -> mdb = $mdb;
}
}
public function ensureTables()
{
if ( $this -> tablesReady )
return;
$this -> mdb -> query(
'CREATE TABLE IF NOT EXISTS `fakturownia_client_mappings` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`external_client_key` VARCHAR(191) NOT NULL,
`external_name` VARCHAR(255) NOT NULL,
`crm_client_id` INT UNSIGNED NOT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_external_client_key` (`external_client_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
);
$this -> mdb -> query(
'CREATE TABLE IF NOT EXISTS `fakturownia_item_mappings` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`external_item_key` VARCHAR(191) NOT NULL,
`external_name` VARCHAR(255) NOT NULL,
`finance_category_id` INT UNSIGNED NOT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_external_item_key` (`external_item_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
);
$this -> mdb -> query(
'CREATE TABLE IF NOT EXISTS `fakturownia_imported_documents` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`external_document_key` VARCHAR(191) NOT NULL,
`document_type` VARCHAR(32) NOT NULL,
`external_id` VARCHAR(64) NOT NULL,
`source_date` DATE NULL,
`amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`finance_operation_ids` TEXT NULL,
`meta_json` LONGTEXT NULL,
`imported_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_external_document_key` (`external_document_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
);
$this -> mdb -> query(
'CREATE TABLE IF NOT EXISTS `fakturownia_unmapped_queue` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`queue_type` VARCHAR(32) NOT NULL,
`external_key` VARCHAR(191) NOT NULL,
`external_name` VARCHAR(255) NOT NULL,
`payload_json` LONGTEXT NULL,
`hits` INT UNSIGNED NOT NULL DEFAULT 1,
`resolved` TINYINT(1) NOT NULL DEFAULT 0,
`first_seen_at` DATETIME NOT NULL,
`last_seen_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_type_key` (`queue_type`, `external_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
);
$this -> mdb -> query(
'CREATE TABLE IF NOT EXISTS `fakturownia_import_state` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`state_key` VARCHAR(191) NOT NULL,
`state_value` LONGTEXT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_state_key` (`state_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
);
$this -> tablesReady = true;
}
public function getClientMapping( $externalClientKey )
{
$this -> ensureTables();
return $this -> mdb -> get( 'fakturownia_client_mappings', '*', [
'external_client_key' => $externalClientKey
] );
}
public function saveClientMapping( $externalClientKey, $externalName, $crmClientId )
{
$this -> ensureTables();
$current = $this -> getClientMapping( $externalClientKey );
$now = date( 'Y-m-d H:i:s' );
if ( $current )
{
$this -> mdb -> update( 'fakturownia_client_mappings', [
'external_name' => $externalName,
'crm_client_id' => (int)$crmClientId,
'updated_at' => $now
], [ 'id' => (int)$current['id'] ] );
}
else
{
$this -> mdb -> insert( 'fakturownia_client_mappings', [
'external_client_key' => $externalClientKey,
'external_name' => $externalName,
'crm_client_id' => (int)$crmClientId,
'created_at' => $now,
'updated_at' => $now
] );
}
$this -> resolveQueueItem( 'client', $externalClientKey );
}
public function getItemMapping( $externalItemKey )
{
$this -> ensureTables();
return $this -> mdb -> get( 'fakturownia_item_mappings', '*', [
'external_item_key' => $externalItemKey
] );
}
public function saveItemMapping( $externalItemKey, $externalName, $financeCategoryId )
{
$this -> ensureTables();
$current = $this -> getItemMapping( $externalItemKey );
$now = date( 'Y-m-d H:i:s' );
if ( $current )
{
$this -> mdb -> update( 'fakturownia_item_mappings', [
'external_name' => $externalName,
'finance_category_id' => (int)$financeCategoryId,
'updated_at' => $now
], [ 'id' => (int)$current['id'] ] );
}
else
{
$this -> mdb -> insert( 'fakturownia_item_mappings', [
'external_item_key' => $externalItemKey,
'external_name' => $externalName,
'finance_category_id' => (int)$financeCategoryId,
'created_at' => $now,
'updated_at' => $now
] );
}
$this -> resolveQueueItem( 'item', $externalItemKey );
}
public function isDocumentImported( $externalDocumentKey )
{
$this -> ensureTables();
return (bool)$this -> mdb -> has( 'fakturownia_imported_documents', [
'external_document_key' => $externalDocumentKey
] );
}
public function markDocumentImported( $externalDocumentKey, $documentType, $externalId, $sourceDate, $amount, $operationIds, $meta )
{
$this -> ensureTables();
$now = date( 'Y-m-d H:i:s' );
$data = [
'external_document_key' => $externalDocumentKey,
'document_type' => $documentType,
'external_id' => (string)$externalId,
'source_date' => $sourceDate,
'amount' => (float)$amount,
'finance_operation_ids' => implode( ',', $operationIds ),
'meta_json' => json_encode( $meta, JSON_UNESCAPED_UNICODE ),
'imported_at' => $now
];
$existing = $this -> mdb -> get( 'fakturownia_imported_documents', 'id', [
'external_document_key' => $externalDocumentKey
] );
if ( $existing )
{
$this -> mdb -> update( 'fakturownia_imported_documents', $data, [
'id' => (int)$existing
] );
return;
}
$this -> mdb -> insert( 'fakturownia_imported_documents', $data );
}
public function queueUnmapped( $queueType, $externalKey, $externalName, $payload )
{
$this -> ensureTables();
$existing = $this -> mdb -> get( 'fakturownia_unmapped_queue', '*', [
'AND' => [
'queue_type' => $queueType,
'external_key' => $externalKey
]
] );
$now = date( 'Y-m-d H:i:s' );
$payloadJson = json_encode( $payload, JSON_UNESCAPED_UNICODE );
if ( $existing )
{
$this -> mdb -> update( 'fakturownia_unmapped_queue', [
'external_name' => $externalName,
'payload_json' => $payloadJson,
'hits[+]' => 1,
'resolved' => 0,
'last_seen_at' => $now
], [ 'id' => (int)$existing['id'] ] );
return;
}
$this -> mdb -> insert( 'fakturownia_unmapped_queue', [
'queue_type' => $queueType,
'external_key' => $externalKey,
'external_name' => $externalName,
'payload_json' => $payloadJson,
'hits' => 1,
'resolved' => 0,
'first_seen_at' => $now,
'last_seen_at' => $now
] );
}
public function pendingClientMappings()
{
$this -> ensureTables();
return $this -> mdb -> select( 'fakturownia_unmapped_queue', '*', [
'AND' => [
'queue_type' => 'client',
'resolved' => 0
],
'ORDER' => [ 'last_seen_at' => 'DESC' ]
] );
}
public function pendingItemMappings()
{
$this -> ensureTables();
return $this -> mdb -> select( 'fakturownia_unmapped_queue', '*', [
'AND' => [
'queue_type' => 'item',
'resolved' => 0,
'external_key[!]' => 'name:faktura bez pozycji'
],
'ORDER' => [ 'last_seen_at' => 'DESC' ]
] );
}
public function saveState( $stateKey, $stateValue )
{
$this -> ensureTables();
$existing = $this -> mdb -> get( 'fakturownia_import_state', '*', [
'state_key' => $stateKey
] );
$now = date( 'Y-m-d H:i:s' );
if ( $existing )
{
$this -> mdb -> update( 'fakturownia_import_state', [
'state_value' => $stateValue,
'updated_at' => $now
], [ 'id' => (int)$existing['id'] ] );
return;
}
$this -> mdb -> insert( 'fakturownia_import_state', [
'state_key' => $stateKey,
'state_value' => $stateValue,
'updated_at' => $now
] );
}
public function getState( $stateKey )
{
$this -> ensureTables();
return $this -> mdb -> get( 'fakturownia_import_state', 'state_value', [
'state_key' => $stateKey
] );
}
private function resolveQueueItem( $queueType, $externalKey )
{
$this -> mdb -> update( 'fakturownia_unmapped_queue', [
'resolved' => 1,
'last_seen_at' => date( 'Y-m-d H:i:s' )
], [
'AND' => [
'queue_type' => $queueType,
'external_key' => $externalKey
]
] );
}
}

View File

@@ -0,0 +1,461 @@
<?php
namespace Domain\Finances;
class FakturowniaInvoiceImporter
{
private $mdb;
private $repo;
private $apiClient;
private $startDate;
private $pageLimit = 100;
public function __construct( $mdb = null )
{
if ( $mdb )
$this -> mdb = $mdb;
else
{
global $mdb;
$this -> mdb = $mdb;
}
$this -> repo = new FakturowniaImportRepository( $this -> mdb );
}
public function import()
{
$this -> repo -> ensureTables();
$config = $this -> resolveConfig();
if ( !$config['ok'] )
return $config;
$this -> startDate = $config['start_date'];
$this -> apiClient = new FakturowniaApiClient(
$config['api_url'],
$config['token'],
$config['page_limit']
);
$this -> pageLimit = (int)$config['page_limit'];
$summary = [
'imported' => 0,
'skipped' => 0,
'unmapped' => 0,
'errors' => 0
];
$summary = $this -> processDocumentType( 'income', $summary );
$summary = $this -> processDocumentType( 'cost', $summary );
$lastSummary = json_encode( [
'at' => date( 'Y-m-d H:i:s' ),
'summary' => $summary
], JSON_UNESCAPED_UNICODE );
$this -> repo -> saveState( 'last_import_summary', $lastSummary );
if ( $summary['imported'] === 0 && $summary['unmapped'] === 0 && $summary['errors'] === 0 )
return [ 'status' => 'empty', 'msg' => 'Import Fakturownia: brak nowych dokumentow.' ];
if ( $summary['errors'] > 0 )
return [ 'status' => 'error', 'msg' => $this -> formatMessage( $summary ), 'summary' => $summary ];
return [ 'status' => 'ok', 'msg' => $this -> formatMessage( $summary ), 'summary' => $summary ];
}
private function processDocumentType( $documentType, $summary )
{
$page = 1;
while ( true )
{
try
{
if ( $documentType === 'income' )
$documents = $this -> apiClient -> fetchSalesDocuments( $this -> startDate, $page );
else
$documents = $this -> apiClient -> fetchCostDocuments( $this -> startDate, $page );
}
catch ( \Throwable $e )
{
$summary['errors']++;
break;
}
if ( !is_array( $documents ) || empty( $documents ) )
break;
$hasRelevantDateInPage = false;
foreach ( $documents as $document )
{
if ( $this -> isDateRelevantForImport( $document ) )
$hasRelevantDateInPage = true;
$result = $this -> processSingleDocument( $document, $documentType );
$summary[ $result ]++;
}
if ( count( $documents ) < $this -> pageLimit )
break;
// API zwraca dokumenty malejaco po czasie. Gdy cala strona jest starsza niz startDate,
// kolejne strony tez beda starsze i nie ma sensu pobierac dalej.
if ( !$hasRelevantDateInPage )
break;
$page++;
if ( $page > 100 )
break;
}
return $summary;
}
private function processSingleDocument( $rawDocument, $documentType )
{
if ( !$this -> matchesDocumentType( $rawDocument, $documentType ) )
return 'skipped';
$document = $this -> normalizeDocument( $rawDocument, $documentType );
if ( !$document )
return 'skipped';
if ( $this -> repo -> isDocumentImported( $document['document_key'] ) )
return 'skipped';
if ( strtotime( $document['date'] ) < strtotime( $this -> startDate ) )
return 'skipped';
$clientMap = $this -> repo -> getClientMapping( $document['client_key'] );
if ( !$clientMap )
{
$this -> repo -> queueUnmapped( 'client', $document['client_key'], $document['client_name'], [
'document_id' => $document['external_id'],
'document_number' => $document['number'],
'document_type' => $documentType,
'tax_no' => $document['client_tax_no']
] );
return 'unmapped';
}
$resolvedPositions = [];
foreach ( $document['positions'] as $position )
{
$itemMap = $this -> repo -> getItemMapping( $position['item_key'] );
if ( !$itemMap )
{
$this -> repo -> queueUnmapped( 'item', $position['item_key'], $position['name'], [
'document_id' => $document['external_id'],
'document_number' => $document['number'],
'document_type' => $documentType
] );
return 'unmapped';
}
$amount = $this -> normalizeAmount( $position['amount'], $documentType );
if ( $amount == 0.0 )
continue;
$resolvedPositions[] = [
'category_id' => (int)$itemMap['finance_category_id'],
'amount' => $amount,
'description' => $this -> buildDescription( $document, $position )
];
}
if ( empty( $resolvedPositions ) )
return 'skipped';
$operationIds = [];
// Cala faktura importuje sie atomowo: albo wszystkie pozycje, albo nic.
$this -> mdb -> pdo -> beginTransaction();
try
{
foreach ( $resolvedPositions as $resolvedPosition )
{
$this -> mdb -> insert( 'finance_operations', [
'date' => $document['date'],
'category_id' => $resolvedPosition['category_id'],
'amount' => $resolvedPosition['amount'],
'description' => $resolvedPosition['description'],
'client_id' => (int)$clientMap['crm_client_id']
] );
$operationIds[] = (int)$this -> mdb -> id();
}
$this -> repo -> markDocumentImported(
$document['document_key'],
$documentType,
$document['external_id'],
$document['date'],
$document['total_amount'],
$operationIds,
[
'number' => $document['number'],
'client_name' => $document['client_name']
]
);
$this -> mdb -> pdo -> commit();
}
catch ( \Throwable $e )
{
if ( $this -> mdb -> pdo ->inTransaction() )
$this -> mdb -> pdo -> rollBack();
throw $e;
}
return 'imported';
}
private function matchesDocumentType( $rawDocument, $documentType )
{
if ( isset( $rawDocument['invoice'] ) && is_array( $rawDocument['invoice'] ) )
$rawDocument = $rawDocument['invoice'];
if ( !is_array( $rawDocument ) )
return false;
if ( !array_key_exists( 'income', $rawDocument ) )
return true;
$incomeFlag = $rawDocument['income'];
$isIncome = !in_array( $incomeFlag, [ false, 0, '0', 'false', 'FALSE' ], true );
if ( $documentType === 'income' )
return $isIncome;
return !$isIncome;
}
private function isDateRelevantForImport( $rawDocument )
{
if ( isset( $rawDocument['invoice'] ) && is_array( $rawDocument['invoice'] ) )
$rawDocument = $rawDocument['invoice'];
if ( !is_array( $rawDocument ) )
return false;
$date = (string)( $rawDocument['issue_date'] ?? $rawDocument['sell_date'] ?? $rawDocument['created_at'] ?? '' );
$date = substr( $date, 0, 10 );
if ( !$date )
return false;
return strtotime( $date ) >= strtotime( $this -> startDate );
}
private function normalizeDocument( $rawDocument, $documentType )
{
if ( isset( $rawDocument['invoice'] ) && is_array( $rawDocument['invoice'] ) )
$rawDocument = $rawDocument['invoice'];
if ( !is_array( $rawDocument ) )
return null;
$externalId = isset( $rawDocument['id'] ) ? (string)$rawDocument['id'] : '';
if ( $externalId === '' )
return null;
$number = (string)( $rawDocument['number'] ?? $rawDocument['full_number'] ?? '' );
$date = (string)( $rawDocument['issue_date'] ?? $rawDocument['sell_date'] ?? $rawDocument['created_at'] ?? date( 'Y-m-d' ) );
$date = substr( $date, 0, 10 );
if ( $documentType === 'income' )
{
$clientName = (string)( $rawDocument['buyer_name'] ?? $rawDocument['client_name'] ?? 'Nieznany klient' );
$clientTaxNo = (string)( $rawDocument['buyer_tax_no'] ?? $rawDocument['tax_no'] ?? '' );
}
else
{
$clientName = (string)( $rawDocument['seller_name'] ?? $rawDocument['client_name'] ?? 'Nieznany kontrahent' );
$clientTaxNo = (string)( $rawDocument['seller_tax_no'] ?? $rawDocument['tax_no'] ?? '' );
}
$positions = [];
$totalAmount = 0.0;
$rawPositions = $this -> resolvePositions( $rawDocument );
foreach ( $rawPositions as $rawPosition )
{
if ( !is_array( $rawPosition ) )
continue;
$name = trim( (string)( $rawPosition['name'] ?? '' ) );
if ( $name === '' )
$name = 'Pozycja';
$amount = $this -> resolvePositionAmount( $rawPosition );
$totalAmount += $amount;
$positions[] = [
'item_key' => $this -> buildItemKey( $rawPosition, $name ),
'name' => $name,
'amount' => $amount
];
}
if ( empty( $positions ) )
{
$fallback = (float)( $rawDocument['price_gross'] ?? $rawDocument['price'] ?? 0 );
$fallbackName = trim( (string)( $rawDocument['product_cache'] ?? '' ) );
if ( $fallbackName === '' )
$fallbackName = 'Faktura bez pozycji';
if ( $fallback != 0.0 )
{
$positions[] = [
'item_key' => $this -> buildItemKey( [], $fallbackName ),
'name' => $fallbackName,
'amount' => $fallback
];
$totalAmount = $fallback;
}
}
if ( empty( $positions ) )
return null;
return [
'external_id' => $externalId,
'document_key' => $documentType . ':' . $externalId,
'number' => $number,
'date' => $date,
'client_name' => $clientName,
'client_tax_no' => $clientTaxNo,
'client_key' => $this -> buildClientKey( $rawDocument, $clientName, $clientTaxNo ),
'positions' => $positions,
'total_amount' => $this -> normalizeAmount( $totalAmount, $documentType )
];
}
private function resolvePositions( $rawDocument )
{
if ( isset( $rawDocument['positions'] ) && is_array( $rawDocument['positions'] ) && !empty( $rawDocument['positions'] ) )
return $rawDocument['positions'];
$invoiceId = isset( $rawDocument['id'] ) ? (int)$rawDocument['id'] : 0;
if ( $invoiceId <= 0 )
return [];
$details = $this -> apiClient -> fetchInvoiceDetails( $invoiceId );
if ( is_array( $details ) && isset( $details['positions'] ) && is_array( $details['positions'] ) )
return $details['positions'];
return [];
}
private function resolvePositionAmount( $position )
{
$quantity = (float)( $position['quantity'] ?? 1 );
if ( $quantity <= 0 )
$quantity = 1;
$totalNet = (float)( $position['total_price_net'] ?? 0 );
if ( $totalNet != 0.0 )
return $totalNet;
$unitNet = (float)( $position['price'] ?? $position['price_net'] ?? 0 );
if ( $unitNet != 0.0 )
return $unitNet * $quantity;
// Fallback dla pozycji, ktore nie maja netto w payloadzie.
$totalGross = (float)( $position['total_price_gross'] ?? 0 );
if ( $totalGross != 0.0 )
return $totalGross;
$unitGross = (float)( $position['price_gross'] ?? 0 );
if ( $unitGross != 0.0 )
return $unitGross * $quantity;
return 0.0;
}
private function buildClientKey( $rawDocument, $clientName, $clientTaxNo )
{
if ( isset( $rawDocument['client_id'] ) && (string)$rawDocument['client_id'] !== '' )
return 'id:' . (string)$rawDocument['client_id'];
if ( $clientTaxNo !== '' )
return 'tax:' . preg_replace( '/\s+/', '', $clientTaxNo );
return 'name:' . $this -> normalizeKeyValue( $clientName );
}
private function buildItemKey( $rawPosition, $name )
{
if ( isset( $rawPosition['product_id'] ) && (string)$rawPosition['product_id'] !== '' )
return 'product:' . (string)$rawPosition['product_id'];
return 'name:' . $this -> normalizeKeyValue( $name );
}
private function normalizeKeyValue( $value )
{
$value = trim( (string)$value );
if ( function_exists( 'mb_strtolower' ) )
return mb_strtolower( $value, 'UTF-8' );
return strtolower( $value );
}
private function normalizeAmount( $amount, $documentType )
{
$amount = (float)$amount;
if ( $documentType === 'cost' )
return abs( $amount ) * -1;
return abs( $amount );
}
private function buildDescription( $document, $position )
{
$parts = [
'Fakturownia'
];
if ( $document['number'] !== '' )
$parts[] = 'FV: ' . $document['number'];
$parts[] = $position['name'];
$parts[] = $document['client_name'];
return implode( ' | ', $parts );
}
private function resolveConfig()
{
$domain = trim( (string)\Env::get( 'FAKTUROWNIA_API_DOMAIN', '' ) );
$token = trim( (string)\Env::get( 'FAKTUROWNIA_API_TOKEN', '' ) );
$startDate = trim( (string)\Env::get( 'FAKTUROWNIA_START_DATE', '' ) );
$pageLimit = (int)\Env::get( 'FAKTUROWNIA_PAGE_LIMIT', 100 );
if ( $domain === '' || $token === '' || $startDate === '' )
return [ 'status' => 'error', 'ok' => false, 'msg' => 'Import Fakturownia: brak konfiguracji w .env.' ];
if ( !preg_match( '/^\d{4}-\d{2}-\d{2}$/', $startDate ) )
return [ 'status' => 'error', 'ok' => false, 'msg' => 'Import Fakturownia: nieprawidlowa data FAKTUROWNIA_START_DATE.' ];
if ( strpos( $domain, 'http://' ) !== 0 && strpos( $domain, 'https://' ) !== 0 )
$domain = 'https://' . $domain;
return [
'ok' => true,
'api_url' => rtrim( $domain, '/' ),
'token' => $token,
'start_date' => $startDate,
'page_limit' => $pageLimit > 0 ? $pageLimit : 100
];
}
private function formatMessage( $summary )
{
return 'Import Fakturownia: zaimportowano ' . (int)$summary['imported']
. ', pominieto ' . (int)$summary['skipped']
. ', brak mapowan ' . (int)$summary['unmapped']
. ', bledy ' . (int)$summary['errors'] . '.';
}
}

View File

@@ -55,6 +55,23 @@ class FinanceRepository
return $this -> mdb -> select( 'crm_client', [ 'id', 'firm' ], [ 'ORDER' => [ 'firm' => 'ASC' ] ] );
}
public function categoriesFlatList()
{
return $this -> mdb -> select( 'finance_categories', [ 'id', 'name', 'group_id', 'parent_id' ], [
'ORDER' => [ 'name' => 'ASC' ]
] );
}
public function clientExists( $clientId )
{
return (bool)$this -> mdb -> has( 'crm_client', [ 'id' => (int)$clientId ] );
}
public function categoryExists( $categoryId )
{
return (bool)$this -> mdb -> has( 'finance_categories', [ 'id' => (int)$categoryId ] );
}
public function clientsWithRevenue( $date_from, $date_to, $group_id )
{
return $this -> mdb -> query(

View File

@@ -2,6 +2,22 @@
class Cron
{
public static function import_finances_from_fakturownia()
{
try
{
$importer = new \Domain\Finances\FakturowniaInvoiceImporter();
return $importer -> import();
}
catch ( \Throwable $e )
{
return [
'status' => 'error',
'msg' => 'Import Fakturownia: ' . $e -> getMessage()
];
}
}
public static function recursive_tasks()
{
global $mdb;

64
autoload/class.Env.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
class Env
{
private static $loaded = false;
private static $values = [];
public static function load( $path = '.env' )
{
if ( self::$loaded )
return;
self::$loaded = true;
if ( !is_string( $path ) || $path === '' || !file_exists( $path ) )
return;
$lines = file( $path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
if ( !is_array( $lines ) )
return;
foreach ( $lines as $line )
{
$line = trim( $line );
if ( $line === '' || strpos( $line, '#' ) === 0 )
continue;
$parts = explode( '=', $line, 2 );
if ( count( $parts ) !== 2 )
continue;
$key = trim( $parts[0] );
$value = trim( $parts[1] );
if ( $key === '' )
continue;
if ( strlen( $value ) >= 2 )
{
$first = $value[0];
$last = $value[strlen( $value ) - 1];
if ( ( $first === '"' && $last === '"' ) || ( $first === "'" && $last === "'" ) )
$value = substr( $value, 1, -1 );
}
self::$values[ $key ] = $value;
$_ENV[ $key ] = $value;
putenv( $key . '=' . $value );
}
}
public static function get( $key, $default = null )
{
self::load();
$value = getenv( $key );
if ( $value !== false )
return $value;
if ( isset( self::$values[ $key ] ) )
return self::$values[ $key ];
return $default;
}
}

View File

@@ -1,4 +1,4 @@
<?php
<?php
error_reporting( E_ALL ^ E_NOTICE ^ E_STRICT ^ E_WARNING ^ E_DEPRECATED );
function __autoload_my_classes( $classname )
@@ -23,6 +23,7 @@ require_once 'libraries/grid/config.php';
require_once 'libraries/rb.php';
session_start();
\Env::load();
if ( !isset( $_SESSION['check'] ) )
{
@@ -53,6 +54,27 @@ $mdb = new medoo( [
return R::getRedBean() -> dispense( $type );
} );
/* diagnostyka: wymuszenie pojedynczego joba cron */
if ( \S::get( 'job' ) === 'fakturownia' )
{
$response = \Cron::import_finances_from_fakturownia();
echo json_encode( $response );
exit;
}
/* import faktur z Fakturowni */
$response = \Cron::import_finances_from_fakturownia();
if ( $response['status'] == 'ok' )
{
echo json_encode( $response );
exit;
}
if ( $response['status'] == 'error' )
{
echo json_encode( $response );
exit;
}
/* import zadan z email */
$response = \Cron::import_tasks_from_email();
if ( $response['status'] == 'ok' )
@@ -66,10 +88,9 @@ if ( $response['status'] == 'error' )
exit;
}
if ( date( 'G' ) > 6 )
{
/* wysyłanie przypomnnień do zadań */
/* wysylanie przypomnien do zadan */
$response = \Cron::tasks_emails();
if ( $response['status'] == 'ok' )
{
@@ -78,7 +99,7 @@ if ( date( 'G' ) > 6 )
}
}
/* dodawanie zadań rekursywnych */
/* dodawanie zadan rekursywnych */
$response = \Cron::recursive_tasks();
if ( $response['status'] == 'ok' )
{

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1750,7 +1750,7 @@ $sidebar-hover-bg: rgba(255, 255, 255, 0.08);
.finance-manager {
display: grid;
gap: 10px;
grid-template-columns: 200px 1fr 500px;
grid-template-columns: 250px 1fr 500px;
padding-top: 25px;
.manage-menu {
@@ -1999,36 +1999,248 @@ $sidebar-hover-bg: rgba(255, 255, 255, 0.08);
.tasks_main_view {
display: flex;
gap: 20px;
align-items: flex-start;
._left_column {
padding: 25px;
background: #fcfcfc;
border-right: 1px solid #e8e8e8;
width: 350px;
width: fit-content;
min-width: 350px;
max-width: 520px;
padding: 0;
background: transparent;
border-right: 0;
label {
font-weight: 400;
font-size: 14px;
.filters_panel {
background: linear-gradient(180deg, #fdfefe 0%, #f4f7fb 100%);
border: 1px solid #dbe2ea;
border-radius: 12px;
padding: 16px;
box-shadow: none;
}
.filters_panel_header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.filters_panel_title {
display: flex;
align-items: center;
gap: 8px;
i {
color: #1f3d72;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #1f3d72;
}
}
.active_filters_badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
color: #425466;
background: #e9eff8;
strong {
margin-left: 4px;
font-size: 13px;
color: #1f3d72;
}
}
.filters_controls {
margin-bottom: 14px;
padding: 12px;
border-radius: 10px;
border: 1px solid #dbe2ea;
background: #fff;
}
.filters_label {
display: block;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: #425466;
}
select[name="filtr"] {
margin-bottom: 10px;
margin-bottom: 12px;
height: 40px;
border-color: #d1dae4;
&:focus {
border-color: #5f88e9;
box-shadow: 0 0 0 3px rgba(95, 136, 233, 0.15);
}
}
._buttons {
display: flex;
gap: 10px;
justify-content: space-between;
gap: 8px;
.btn {
flex: 1;
min-height: 34px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.btn-dark {
background: #3d495e;
&:hover {
background: #2f394c;
}
}
}
.filters_section {
border: 1px solid #dbe2ea;
border-radius: 10px;
background: #fff;
margin-bottom: 12px;
overflow: hidden;
}
.filters_section_header {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: #f4f7fb;
border-bottom: 1px solid #e3eaf2;
h4 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #243447;
}
.filters_search {
max-width: 170px;
height: 32px;
font-size: 12px;
padding: 4px 10px;
border-color: #d2dbe6;
}
}
.filters_section_list {
max-height: 270px;
overflow-y: auto;
padding: 6px;
}
.filters_section._projects .filters_section_list {
max-height: none;
overflow: visible;
}
.filters_item {
border-radius: 8px;
transition: background 0.2s ease;
&:hover {
background: #f4f7fb;
}
}
.project_row,
._user {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 7px;
label {
margin: 0;
flex: 1;
font-weight: 400;
font-size: 14px;
white-space: nowrap;
}
}
.project_count {
display: inline-block;
margin-left: 4px;
min-width: 26px;
padding: 0 7px;
border-radius: 999px;
background: #1f3d72;
color: #fff;
font-weight: 700;
font-size: 11px;
line-height: 19px;
text-align: center;
vertical-align: middle;
}
.project_delete_inline {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 5px;
border: 1px solid #cc563d;
background: #cc563d;
color: #fff;
text-decoration: none;
transition: all 0.2s ease;
margin: 1px 0;
&:hover {
background: #b74831;
border-color: #b74831;
}
i {
font-size: 10px;
}
}
}
._right_column {
flex: 1;
max-width: calc(100% - 350px);
max-width: none;
min-width: 0;
}
}
@media (max-width: 1200px) {
.tasks_main_view {
flex-direction: column;
._left_column,
._right_column {
max-width: 100%;
width: 100%;
}
}
}
.gantt-target {
.gantt-container.gantt-draggable {
cursor: grab;
}
.gantt-container.gantt-dragging {
cursor: grabbing;
}
}

View File

@@ -0,0 +1,148 @@
<?php
$esc = function( $value )
{
return htmlspecialchars( (string)$value, ENT_QUOTES, 'UTF-8' );
};
?>
<style>
.fakturownia-panel {
border-radius: 6px;
overflow: hidden;
}
.fakturownia-panel .panel-heading {
padding: 12px 14px;
}
.fakturownia-panel .panel-title {
margin: 0;
line-height: 1.3;
}
.fakturownia-panel .panel-body {
padding-bottom: 10px;
}
.fakturownia-panel .panel-body > :last-child {
margin-bottom: 0;
}
.fakturownia-panel .select2-container {
min-width: 320px !important;
}
</style>
<div class="panel panel-default fakturownia-panel">
<div class="panel-heading">
<h4 class="panel-title">Import faktur z Fakturowni</h4>
</div>
<div class="panel-body">
<? if ( is_array( $this -> fakturownia_last_summary ) && isset( $this -> fakturownia_last_summary['summary'] ) ): ?>
<div class="alert alert-info mb15">
Ostatni import: <?= $esc( $this -> fakturownia_last_summary['at'] ?? '-' ); ?><br>
Zaimportowano: <strong><?= (int)( $this -> fakturownia_last_summary['summary']['imported'] ?? 0 ); ?></strong>,
pominieto: <strong><?= (int)( $this -> fakturownia_last_summary['summary']['skipped'] ?? 0 ); ?></strong>,
brak mapowan: <strong><?= (int)( $this -> fakturownia_last_summary['summary']['unmapped'] ?? 0 ); ?></strong>,
bledy: <strong><?= (int)( $this -> fakturownia_last_summary['summary']['errors'] ?? 0 ); ?></strong>
</div>
<? endif; ?>
<? if ( is_array( $this -> fakturownia_pending_clients ) && count( $this -> fakturownia_pending_clients ) ): ?>
<h5>Brakujace mapowania klientow</h5>
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>Klient z Fakturowni</th>
<th>CRM klient</th>
<th style="width: 130px;">Akcja</th>
</tr>
</thead>
<tbody>
<? foreach ( $this -> fakturownia_pending_clients as $row ): ?>
<tr>
<td>
<?= $esc( $row['external_name'] ); ?><br>
<small class="text-muted">Klucz: <?= $esc( $row['external_key'] ); ?> | wystapienia: <?= (int)$row['hits']; ?></small>
</td>
<td>
<form method="post" action="/finances/fakturownia_client_mapping_save/" class="form-inline">
<input type="hidden" name="csrf_token" value="<?= \S::csrf_token(); ?>">
<input type="hidden" name="external_key" value="<?= $esc( $row['external_key'] ); ?>">
<input type="hidden" name="external_name" value="<?= $esc( $row['external_name'] ); ?>">
<select name="crm_client_id" class="form-control input-sm js-fakturownia-client-select" required>
<option value="">Wybierz klienta</option>
<? foreach ( $this -> fakturownia_crm_clients as $client ): ?>
<option value="<?= (int)$client['id']; ?>"><?= $esc( $client['firm'] ); ?></option>
<? endforeach; ?>
</select>
</td>
<td>
<button type="submit" class="btn btn-success btn-sm">Zapisz</button>
</form>
</td>
</tr>
<? endforeach; ?>
</tbody>
</table>
<? endif; ?>
<? if ( is_array( $this -> fakturownia_pending_items ) && count( $this -> fakturownia_pending_items ) ): ?>
<h5 class="mt20">Brakujace mapowania produktow/uslug</h5>
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>Pozycja z faktury</th>
<th>Kategoria finansowa</th>
<th style="width: 130px;">Akcja</th>
</tr>
</thead>
<tbody>
<? foreach ( $this -> fakturownia_pending_items as $row ): ?>
<tr>
<td>
<?= $esc( $row['external_name'] ); ?><br>
<small class="text-muted">Klucz: <?= $esc( $row['external_key'] ); ?> | wystapienia: <?= (int)$row['hits']; ?></small>
</td>
<td>
<form method="post" action="/finances/fakturownia_item_mapping_save/" class="form-inline">
<input type="hidden" name="csrf_token" value="<?= \S::csrf_token(); ?>">
<input type="hidden" name="external_key" value="<?= $esc( $row['external_key'] ); ?>">
<input type="hidden" name="external_name" value="<?= $esc( $row['external_name'] ); ?>">
<select name="finance_category_id" class="form-control input-sm" required>
<option value="">Wybierz kategorie</option>
<? foreach ( $this -> fakturownia_categories as $category ): ?>
<option value="<?= (int)$category['id']; ?>">
<?= $esc( $category['name'] ); ?> (grupa: <?= (int)$category['group_id']; ?>)
</option>
<? endforeach; ?>
</select>
</td>
<td>
<button type="submit" class="btn btn-success btn-sm">Zapisz</button>
</form>
</td>
</tr>
<? endforeach; ?>
</tbody>
</table>
<? endif; ?>
</div>
</div>
<script type="text/javascript">
(function() {
if ( typeof jQuery === 'undefined' || !jQuery.fn.select2 )
return;
var selects = jQuery( '.js-fakturownia-client-select' );
if ( !selects.length )
return;
selects.select2({
theme: 'bootstrap-5',
width: '100%',
placeholder: 'Wyszukaj klienta CRM'
});
selects.on( 'select2:open', function() {
setTimeout( function() {
var searchField = document.querySelector( '.select2-container--open .select2-search__field' );
if ( searchField )
searchField.focus();
}, 0 );
} );
})();
</script>

View File

@@ -46,6 +46,9 @@
</div>
</div>
</div>
<div class="finance-import-panel" style="clear: both; margin-top: 15px;">
<? include 'fakturownia-import-panel.php'; ?>
</div>
<div class="finance-manager">
<div class="column-left">
<div class="clients-list-container">
@@ -307,4 +310,4 @@ $(function() {
$(this).parents('.input-group').children('input').trigger('click');
});
});
</script>
</script>

View File

@@ -1,48 +1,70 @@
<div class="tasks_main_view">
<div class="_left_column">
<select name="filtr" class="form-control">
<option value="">--- wybierz filtr ---</option>
<? foreach ( $this -> tasks_filtrs as $filtr ):?>
<option value="<?= $filtr[ 'id' ];?>"><?= $filtr[ 'name' ];?></option>
<? endforeach;?>
</select>
<div class="_buttons">
<a href="#" class="btn btn-success btn_small" id="_new_filtr">zapisz</a>
<a href="#" class="btn btn-primary btn_small" id="_update_filtr">aktualizuj</a>
<!-- set default -->
<a href="#" class="btn btn-dark btn_small" id="_set_default_filtr">domy&#347;lny</a>
</div>
<div class="_projects">
<h4>Projekty</h4>
<? foreach ( $this -> projects as $project ):?>
<div class="_project">
<div class="project_row">
<label for="project_<?= $project[ 'id' ];?>">
<input type="checkbox" class="g-checkbox" name="projects" value="<?= $project[ 'id' ];?>" <? if ( is_array( $this -> selected_projects ) and in_array( $project['id'], $this -> selected_projects ) ):?>checked<? endif;?>>
<?= $project[ 'name' ];?> <span class="project_count">(<?= (int)$project['total_tasks'];?>)</span>
</label>
<? if ( \controls\Users::permissions( $this -> user['id'], 'projects', 'project_delete' ) ):?>
<a href="#" class="project_delete_inline" project_id="<?= (int)$project['id'];?>" project_name="<?= htmlspecialchars( $project['name'] );?>" title="Usuń projekt">
<i class="fa fa-trash"></i>
</a>
<? endif;?>
<div class="filters_panel">
<div class="filters_panel_header">
<div class="filters_panel_title">
<i class="fa fa-filter"></i>
<h3>Filtry</h3>
</div>
<span class="active_filters_badge">Aktywne: <strong id="active_filters_count">0</strong></span>
</div>
<div class="filters_controls">
<label class="filters_label" for="tasks_saved_filter">Zapisany filtr</label>
<select name="filtr" id="tasks_saved_filter" class="form-control">
<option value="">--- wybierz filtr ---</option>
<? foreach ( $this -> tasks_filtrs as $filtr ):?>
<option value="<?= $filtr[ 'id' ];?>"><?= $filtr[ 'name' ];?></option>
<? endforeach;?>
</select>
<div class="_buttons">
<a href="#" class="btn btn-success btn_small" id="_new_filtr"><i class="fa fa-plus"></i>Zapisz</a>
<a href="#" class="btn btn-primary btn_small" id="_update_filtr"><i class="fa fa-refresh"></i>Aktualizuj</a>
<!-- set default -->
<a href="#" class="btn btn-dark btn_small" id="_set_default_filtr"><i class="fa fa-star-o"></i>Domy&#347;lny</a>
</div>
</div>
<div class="filters_section _projects">
<div class="filters_section_header">
<h4>Projekty</h4>
<input type="text" class="form-control filters_search" data-filter-target="projects" placeholder="Szukaj projektu...">
</div>
<div class="filters_section_list">
<? foreach ( $this -> projects as $project ):?>
<div class="_project filters_item" data-filter-item="projects">
<div class="project_row">
<label for="project_<?= $project[ 'id' ];?>">
<input id="project_<?= $project[ 'id' ];?>" type="checkbox" class="g-checkbox" name="projects" value="<?= $project[ 'id' ];?>" <? if ( is_array( $this -> selected_projects ) and in_array( $project['id'], $this -> selected_projects ) ):?>checked<? endif;?>>
<?= $project[ 'name' ];?> <span class="project_count">(<?= (int)$project['total_tasks'];?>)</span>
</label>
<? if ( \controls\Users::permissions( $this -> user['id'], 'projects', 'project_delete' ) ):?>
<a href="#" class="project_delete_inline" project_id="<?= (int)$project['id'];?>" project_name="<?= htmlspecialchars( $project['name'] );?>" title="Usu&#324; projekt">
<i class="fa fa-trash"></i>
</a>
<? endif;?>
</div>
</div>
<? endforeach;?>
</div>
</div>
<? if ( $this -> user['id'] == 1 ):?>
<div class="filters_section _users">
<div class="filters_section_header">
<h4>U&#380;ytkownicy</h4>
<input type="text" class="form-control filters_search" data-filter-target="users" placeholder="Szukaj u&#380;ytkownika...">
</div>
<div class="filters_section_list">
<? foreach ( $this -> users as $user ):?>
<div class="_user filters_item" data-filter-item="users">
<label for="user_<?= $user[ 'id' ];?>">
<input id="user_<?= $user[ 'id' ];?>" type="checkbox" class="g-checkbox" name="users" value="<?= $user[ 'id' ];?>" <? if ( is_array( $this -> selected_users ) and in_array( $user['id'], $this -> selected_users ) ):?>checked<? endif;?>>
<?= $user[ 'name' ];?> <?= $user[ 'surname' ];?>
</label>
</div>
<? endforeach;?>
</div>
</div>
<? endforeach;?>
<? endif;?>
</div>
<? if ( $this -> user['id'] == 1 ):?>
<div class="_users">
<h4>U&#380;ytkownicy</h4>
<? foreach ( $this -> users as $user ):?>
<div class="_user">
<label for="user_<?= $user[ 'id' ];?>">
<input type="checkbox" class="g-checkbox" name="users" value="<?= $user[ 'id' ];?>" <? if ( is_array( $this -> selected_users ) and in_array( $user['id'], $this -> selected_users ) ):?>checked<? endif;?>>
<?= $user[ 'name' ];?> <?= $user[ 'surname' ];?>
</label>
</div>
<? endforeach;?>
</div>
<? endif;?>
</div>
<div class="_right_column">
<div class="action_menu">
@@ -134,79 +156,23 @@
<div class="task_popup">
</div>
<style type="text/css">
.tasks_main_view ._left_column {
width: fit-content;
min-width: 350px;
max-width: 520px;
}
.tasks_main_view ._right_column {
flex: 1;
max-width: none;
min-width: 0;
}
.tasks_main_view ._left_column ._projects ._project .project_row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.tasks_main_view ._left_column ._projects ._project .project_row label {
margin: 0;
flex: 1;
white-space: nowrap;
}
.tasks_main_view ._left_column ._projects ._project .project_row .project_count {
display: inline-block;
margin-left: 3px;
padding: 0 6px;
border-radius: 10px;
background: #1f3d72;
color: #fff;
font-weight: 700;
font-size: 11px;
line-height: 18px;
vertical-align: middle;
}
.tasks_main_view ._left_column ._projects ._project .project_delete_inline {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
border: 1px solid #cc563d;
background: #cc563d;
color: #fff;
text-decoration: none;
transition: all .2s ease;
margin: 1px 0;
}
.tasks_main_view ._left_column ._projects ._project .project_delete_inline:hover {
background: #b74831;
border-color: #b74831;
}
.tasks_main_view ._left_column ._projects ._project .project_delete_inline i {
font-size: 10px;
}
.gantt-target .gantt-container.gantt-draggable {
cursor: grab;
}
.gantt-target .gantt-container.gantt-dragging {
cursor: grabbing;
}
</style>
<script type="text/javascript">
let isProgrammaticUpdate = false;
function updateActiveFiltersCounter()
{
var activeFiltersCount = $( '.tasks_main_view input.g-checkbox:checked' ).length;
$( '#active_filters_count' ).text( activeFiltersCount );
}
function applyFiltersSearch( target, value )
{
var normalizedValue = value.toLowerCase().trim();
$( '.tasks_main_view [data-filter-item="' + target + '"]' ).each(function() {
var itemText = $( this ).text().toLowerCase();
var shouldShow = normalizedValue === '' || itemText.indexOf( normalizedValue ) !== -1;
$( this ).toggle( shouldShow );
});
}
var tasks = [
<?
@@ -1368,7 +1334,8 @@
});
isProgrammaticUpdate = false;
// wywołuj z tablicami, nie ze stringami
updateActiveFiltersCounter();
// wywoluj z tablicami, nie ze stringami
reload_tasks(projectsArr, usersArr);
}
}
@@ -1512,6 +1479,13 @@
radioClass: 'iradio_square-blue',
});
updateActiveFiltersCounter();
$( '.tasks_main_view' ).on( 'input', '.filters_search', function() {
var target = $( this ).attr( 'data-filter-target' );
applyFiltersSearch( target, $( this ).val() );
});
$(".tasks_main_view input.g-checkbox").on('ifChanged', function (e) {
$(this).trigger("change", e);
});
@@ -1529,6 +1503,7 @@
}).get();
projects.join( "," );
users.join( "," );
updateActiveFiltersCounter();
reload_tasks( projects, users );
});