excel preview & file handling
This commit is contained in:
255
DOKUMENTACIA.md
255
DOKUMENTACIA.md
@@ -1,7 +1,7 @@
|
|||||||
# 📚 CRM SERVER - KOMPLETNÁ DOKUMENTÁCIA
|
# 📚 CRM SERVER - KOMPLETNÁ DOKUMENTÁCIA
|
||||||
|
|
||||||
> Vytvorené: 2025-11-21
|
> Vytvorené: 2025-11-21
|
||||||
> Verzia: 1.0
|
> Verzia: 1.1
|
||||||
> Backend: Node.js + Express + Drizzle ORM + PostgreSQL
|
> Backend: Node.js + Express + Drizzle ORM + PostgreSQL
|
||||||
> Frontend: React
|
> Frontend: React
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
- **Email:** JMAP protocol (Truemail.sk)
|
- **Email:** JMAP protocol (Truemail.sk)
|
||||||
- **Encryption:** AES-256-GCM (email passwords)
|
- **Encryption:** AES-256-GCM (email passwords)
|
||||||
- **File Upload:** Multer
|
- **File Upload:** Multer
|
||||||
|
- **Time Tracking:** Real-time timer s automatickým stop/start
|
||||||
|
|
||||||
### Databázové Tabuľky
|
### Databázové Tabuľky
|
||||||
```
|
```
|
||||||
@@ -42,6 +43,8 @@ users → userEmailAccounts ← emailAccounts
|
|||||||
companies → projects → todos → notes
|
companies → projects → todos → notes
|
||||||
↓
|
↓
|
||||||
timesheets
|
timesheets
|
||||||
|
|
||||||
|
users → timeEntries → projects/todos/companies
|
||||||
```
|
```
|
||||||
|
|
||||||
**Vzťahy:**
|
**Vzťahy:**
|
||||||
@@ -49,6 +52,8 @@ companies → projects → todos → notes
|
|||||||
- `companies` → `projects`: One-to-many (company môže mať viac projektov)
|
- `companies` → `projects`: One-to-many (company môže mať viac projektov)
|
||||||
- `projects` → `todos`, `notes`, `timesheets`: One-to-many
|
- `projects` → `todos`, `notes`, `timesheets`: One-to-many
|
||||||
- `users` → `todos.assignedTo`: One-to-many (user má assigned úlohy)
|
- `users` → `todos.assignedTo`: One-to-many (user má assigned úlohy)
|
||||||
|
- `users` → `timeEntries`: One-to-many (user má záznamy času)
|
||||||
|
- `timeEntries` → `projects`, `todos`, `companies`: Optional Many-to-one
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -497,7 +502,81 @@ validateJmapCredentials(email, password)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 11. audit.service.js
|
### 11. time-tracking.service.js
|
||||||
|
**Účel:** Sledovanie odpracovaného času
|
||||||
|
|
||||||
|
**Databáza:** `timeEntries`, `projects`, `todos`, `companies`, `users`
|
||||||
|
|
||||||
|
**Metódy:**
|
||||||
|
```javascript
|
||||||
|
startTimeEntry(userId, data)
|
||||||
|
→ Validate: project/todo/company exists (ak sú poskytnuté)
|
||||||
|
→ Check: existujúci bežiaci časovač
|
||||||
|
→ Ak beží → automaticky ho zastaví
|
||||||
|
→ INSERT INTO timeEntries
|
||||||
|
→ isRunning = true, startTime = NOW()
|
||||||
|
|
||||||
|
stopTimeEntry(entryId, userId, data)
|
||||||
|
→ Validate: ownership, isRunning = true
|
||||||
|
→ Počíta duration v minútach
|
||||||
|
→ UPDATE timeEntries SET endTime, duration, isRunning = false
|
||||||
|
→ Optional: update projectId, todoId, companyId, description
|
||||||
|
|
||||||
|
getRunningTimeEntry(userId)
|
||||||
|
→ SELECT * WHERE userId AND isRunning = true
|
||||||
|
→ Vráti aktuálny bežiaci časovač (alebo null)
|
||||||
|
|
||||||
|
getAllTimeEntries(userId, filters)
|
||||||
|
→ Filter: projectId, todoId, companyId, startDate, endDate
|
||||||
|
→ SELECT * FROM timeEntries WHERE userId
|
||||||
|
→ ORDER BY startTime DESC
|
||||||
|
|
||||||
|
getMonthlyTimeEntries(userId, year, month)
|
||||||
|
→ SELECT * WHERE userId AND startTime BETWEEN month range
|
||||||
|
→ Používa sa pre mesačný prehľad
|
||||||
|
|
||||||
|
getTimeEntryById(entryId)
|
||||||
|
→ SELECT * WHERE id
|
||||||
|
→ Throw NotFoundError ak neexistuje
|
||||||
|
|
||||||
|
getTimeEntryWithRelations(entryId)
|
||||||
|
→ LEFT JOIN project, todo, company
|
||||||
|
→ Vráti všetko v jednom objekte
|
||||||
|
|
||||||
|
updateTimeEntry(entryId, userId, data)
|
||||||
|
→ Validate: ownership, NOT isRunning (nemožno upraviť bežiaci)
|
||||||
|
→ Update: startTime, endTime, projectId, todoId, companyId, description
|
||||||
|
→ Prepočíta duration ak sa zmení čas
|
||||||
|
→ Set isEdited = true
|
||||||
|
|
||||||
|
deleteTimeEntry(entryId, userId)
|
||||||
|
→ Validate: ownership, NOT isRunning
|
||||||
|
→ DELETE FROM timeEntries
|
||||||
|
|
||||||
|
getMonthlyStats(userId, year, month)
|
||||||
|
→ Volá getMonthlyTimeEntries()
|
||||||
|
→ Počíta štatistiky:
|
||||||
|
- totalMinutes / totalHours
|
||||||
|
- daysWorked (unique days)
|
||||||
|
- averagePerDay
|
||||||
|
- byProject (čas per projekt)
|
||||||
|
- byCompany (čas per firma)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Volá:**
|
||||||
|
- Databázu (timeEntries, projects, todos, companies)
|
||||||
|
- `utils/errors.NotFoundError`
|
||||||
|
- `utils/errors.BadRequestError`
|
||||||
|
|
||||||
|
**Dôležité poznámky:**
|
||||||
|
- **Auto-stop:** Pri štarte nového časovača sa automaticky zastaví predchádzajúci
|
||||||
|
- **Duration:** Ukladá sa v minútach (Math.round)
|
||||||
|
- **isEdited flag:** Označuje manuálne upravené záznamy
|
||||||
|
- **isRunning:** Iba jeden časovač môže byť aktívny pre usera
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. audit.service.js
|
||||||
**Účel:** Audit logging pre compliance
|
**Účel:** Audit logging pre compliance
|
||||||
|
|
||||||
**Databáza:** `auditLogs`
|
**Databáza:** `auditLogs`
|
||||||
@@ -1375,6 +1454,114 @@ Volá: jmap.service.sendEmail()
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### ⏱️ TIME TRACKING
|
||||||
|
|
||||||
|
#### POST /api/time-tracking/start
|
||||||
|
```
|
||||||
|
Účel: Spustiť nový časovač
|
||||||
|
Body: { projectId?, todoId?, companyId?, description? }
|
||||||
|
Auth: Áno
|
||||||
|
Response: { entry with isRunning: true }
|
||||||
|
Efekt:
|
||||||
|
- Automaticky zastaví predchádzajúci bežiaci časovač (ak existuje)
|
||||||
|
- Vytvorí nový time entry s startTime = NOW()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /api/time-tracking/:entryId/stop
|
||||||
|
```
|
||||||
|
Účel: Zastaviť bežiaci časovač
|
||||||
|
Params: entryId (UUID)
|
||||||
|
Body: { projectId?, todoId?, companyId?, description? }
|
||||||
|
Auth: Áno
|
||||||
|
Response: { entry with endTime, duration, isRunning: false }
|
||||||
|
Efekt:
|
||||||
|
- Nastaví endTime = NOW()
|
||||||
|
- Vypočíta duration v minútach
|
||||||
|
- Optional: update projektId/todoId/companyId/description
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /api/time-tracking/running
|
||||||
|
```
|
||||||
|
Účel: Získať aktuálny bežiaci časovač
|
||||||
|
Auth: Áno
|
||||||
|
Response: { entry } alebo null
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /api/time-tracking?projectId=&todoId=&companyId=&startDate=&endDate=
|
||||||
|
```
|
||||||
|
Účel: Zoznam všetkých time entries s filtrami
|
||||||
|
Query: projectId, todoId, companyId, startDate, endDate (all optional)
|
||||||
|
Auth: Áno
|
||||||
|
Response: Array of time entries
|
||||||
|
Order: DESC by startTime
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /api/time-tracking/month/:year/:month
|
||||||
|
```
|
||||||
|
Účel: Mesačný prehľad time entries
|
||||||
|
Params: year (YYYY), month (1-12)
|
||||||
|
Auth: Áno
|
||||||
|
Response: Array of entries pre daný mesiac
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /api/time-tracking/stats/monthly/:year/:month
|
||||||
|
```
|
||||||
|
Účel: Mesačné štatistiky
|
||||||
|
Params: year (YYYY), month (1-12)
|
||||||
|
Auth: Áno
|
||||||
|
Response: {
|
||||||
|
totalMinutes,
|
||||||
|
totalHours,
|
||||||
|
remainingMinutes,
|
||||||
|
daysWorked,
|
||||||
|
averagePerDay,
|
||||||
|
entriesCount,
|
||||||
|
byProject: { projectId: minutes },
|
||||||
|
byCompany: { companyId: minutes }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /api/time-tracking/:entryId
|
||||||
|
```
|
||||||
|
Účel: Detail time entry
|
||||||
|
Params: entryId (UUID)
|
||||||
|
Auth: Áno
|
||||||
|
Response: Single time entry
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /api/time-tracking/:entryId/details
|
||||||
|
```
|
||||||
|
Účel: Detail time entry s reláciami
|
||||||
|
Params: entryId (UUID)
|
||||||
|
Auth: Áno
|
||||||
|
Response: { ...entry, project, todo, company }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PATCH /api/time-tracking/:entryId
|
||||||
|
```
|
||||||
|
Účel: Upraviť time entry
|
||||||
|
Params: entryId (UUID)
|
||||||
|
Body: { startTime?, endTime?, projectId?, todoId?, companyId?, description? }
|
||||||
|
Auth: Áno
|
||||||
|
Validation:
|
||||||
|
- Entry nesmie byť isRunning (nemožno upraviť bežiaci časovač)
|
||||||
|
- User musí byť owner
|
||||||
|
- Pri zmene času sa prepočíta duration
|
||||||
|
Response: Updated entry with isEdited: true
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DELETE /api/time-tracking/:entryId
|
||||||
|
```
|
||||||
|
Účel: Zmazať time entry
|
||||||
|
Params: entryId (UUID)
|
||||||
|
Auth: Áno
|
||||||
|
Validation:
|
||||||
|
- Entry nesmie byť isRunning
|
||||||
|
- User musí byť owner
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 📊 TIMESHEETS
|
### 📊 TIMESHEETS
|
||||||
|
|
||||||
#### POST /api/timesheets/upload
|
#### POST /api/timesheets/upload
|
||||||
@@ -1455,6 +1642,18 @@ email.controller
|
|||||||
→ jmapRequest() → JMAP Server
|
→ jmapRequest() → JMAP Server
|
||||||
→ INSERT email to DB (sent)
|
→ INSERT email to DB (sent)
|
||||||
|
|
||||||
|
TIME TRACKING:
|
||||||
|
time-tracking.controller
|
||||||
|
→ time-tracking.service.startTimeEntry()
|
||||||
|
→ Check running entry (auto-stop if exists)
|
||||||
|
→ Validate project/todo/company
|
||||||
|
→ INSERT timeEntries
|
||||||
|
→ time-tracking.service.stopTimeEntry()
|
||||||
|
→ Calculate duration
|
||||||
|
→ UPDATE timeEntries (set endTime, isRunning = false)
|
||||||
|
→ time-tracking.service.getMonthlyStats()
|
||||||
|
→ Aggregate statistics by project/company
|
||||||
|
|
||||||
AUDIT:
|
AUDIT:
|
||||||
Rôzne controllers
|
Rôzne controllers
|
||||||
→ audit.service.logAuditEvent()
|
→ audit.service.logAuditEvent()
|
||||||
@@ -1478,6 +1677,7 @@ Rôzne controllers
|
|||||||
- `company.service` → database, errors
|
- `company.service` → database, errors
|
||||||
- `todo.service` → database, errors
|
- `todo.service` → database, errors
|
||||||
- `note.service` → database, errors
|
- `note.service` → database, errors
|
||||||
|
- `time-tracking.service` → database, errors
|
||||||
|
|
||||||
**Tier 4 (Multiple services):**
|
**Tier 4 (Multiple services):**
|
||||||
- `contact.service` → company.service, jmap.service
|
- `contact.service` → company.service, jmap.service
|
||||||
@@ -1509,12 +1709,14 @@ Rôzne controllers
|
|||||||
|
|
||||||
### Možné zlepšenia
|
### Možné zlepšenia
|
||||||
1. Pagination na všetkých list endpointoch
|
1. Pagination na všetkých list endpointoch
|
||||||
2. WebSocket pre real-time notifications
|
2. WebSocket pre real-time notifications (time tracking updates, email sync)
|
||||||
3. Background jobs pre email sync (Bull/Redis)
|
3. Background jobs pre email sync (Bull/Redis)
|
||||||
4. Cache layer (Redis) pre často čítané dáta
|
4. Cache layer (Redis) pre často čítané dáta
|
||||||
5. API versioning (/api/v1/)
|
5. API versioning (/api/v1/)
|
||||||
6. GraphQL ako alternatíva k REST
|
6. GraphQL ako alternatíva k REST
|
||||||
7. Implement standalone Notes UI alebo vymazať routes
|
7. Implement standalone Notes UI alebo vymazať routes
|
||||||
|
8. Time tracking export do CSV/Excel pre reporting
|
||||||
|
9. Team time tracking dashboard (admin view všetkých userov)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1697,6 +1899,51 @@ req.userRole // 'admin' | 'member'
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 7. Time Tracking - Auto-stop Behavior
|
||||||
|
|
||||||
|
**PROBLÉM:** User môže mať spustený iba jeden časovač naraz.
|
||||||
|
|
||||||
|
**RIEŠENIE - Automatický stop:**
|
||||||
|
```javascript
|
||||||
|
// Pri štarte nového časovača
|
||||||
|
if (existingRunning) {
|
||||||
|
// Automaticky zastaví predchádzajúci časovač
|
||||||
|
const endTime = new Date();
|
||||||
|
const duration = Math.round((endTime - startTime) / 60000); // minúty
|
||||||
|
await db.update(timeEntries)
|
||||||
|
.set({ endTime, duration, isRunning: false })
|
||||||
|
.where(eq(timeEntries.id, existingRunning.id));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validačné pravidlá:**
|
||||||
|
```javascript
|
||||||
|
// ✅ POVOLENÉ
|
||||||
|
- Spustiť nový časovač (auto-stop predchádzajúceho)
|
||||||
|
- Upraviť zastavený časovač
|
||||||
|
- Zmazať zastavený časovač
|
||||||
|
|
||||||
|
// ❌ ZAKÁZANÉ
|
||||||
|
- Upraviť bežiaci časovač (musí sa najprv zastaviť)
|
||||||
|
- Zmazať bežiaci časovač (musí sa najprv zastaviť)
|
||||||
|
- Zastaviť časovač iného usera
|
||||||
|
```
|
||||||
|
|
||||||
|
**Duration calculation:**
|
||||||
|
- Ukladá sa v **minútach** (Math.round)
|
||||||
|
- Počíta sa z rozdelu: `(endTime - startTime) / 60000`
|
||||||
|
- Frontend zobrazuje: hodiny + minúty (napr. "2h 35m")
|
||||||
|
|
||||||
|
**isEdited flag:**
|
||||||
|
- Automaticky nastavený pri manuálnej úprave
|
||||||
|
- Indikuje, že čas bol zmenený používateľom (nie auto-tracked)
|
||||||
|
|
||||||
|
**Kde sa používa:**
|
||||||
|
- `time-tracking.service.js`: `startTimeEntry()`, `updateTimeEntry()`
|
||||||
|
- Frontend: Time tracking komponenty s real-time countdown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🔍 DEBUGGING TIPS
|
## 🔍 DEBUGGING TIPS
|
||||||
|
|
||||||
### 1. Server beží starý kód
|
### 1. Server beží starý kód
|
||||||
@@ -1732,6 +1979,6 @@ console.log('[DEBUG] JMAP validation:', valid);
|
|||||||
---
|
---
|
||||||
|
|
||||||
**Vytvorené:** 2025-11-21
|
**Vytvorené:** 2025-11-21
|
||||||
**Posledná aktualizácia:** 2025-11-21
|
**Posledná aktualizácia:** 2025-11-24
|
||||||
**Autor:** CRM Server Team
|
**Autor:** CRM Server Team
|
||||||
**Kontakt:** crm-server documentation
|
**Kontakt:** crm-server documentation
|
||||||
|
|||||||
808
package-lock.json
generated
808
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"express-rate-limit": "^8.2.1",
|
"express-rate-limit": "^8.2.1",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
|
|||||||
@@ -132,6 +132,32 @@ export const getMonthlyTimeEntries = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate timesheet file for a month
|
||||||
|
* POST /api/time-tracking/month/:year/:month/generate
|
||||||
|
*/
|
||||||
|
export const generateMonthlyTimesheet = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { year, month } = req.params;
|
||||||
|
|
||||||
|
const result = await timeTrackingService.generateMonthlyTimesheet(
|
||||||
|
userId,
|
||||||
|
parseInt(year),
|
||||||
|
parseInt(month)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'Timesheet bol vygenerovaný',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get time entry by ID
|
* Get time entry by ID
|
||||||
* GET /api/time-tracking/:entryId
|
* GET /api/time-tracking/:entryId
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const uploadTimesheet = async (req, res) => {
|
|||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
year: parseInt(year),
|
year: parseInt(year),
|
||||||
month: parseInt(month),
|
month: parseInt(month),
|
||||||
|
isGenerated: false,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ export const uploadTimesheet = async (req, res) => {
|
|||||||
fileSize: newTimesheet.fileSize,
|
fileSize: newTimesheet.fileSize,
|
||||||
year: newTimesheet.year,
|
year: newTimesheet.year,
|
||||||
month: newTimesheet.month,
|
month: newTimesheet.month,
|
||||||
|
isGenerated: newTimesheet.isGenerated,
|
||||||
uploadedAt: newTimesheet.uploadedAt,
|
uploadedAt: newTimesheet.uploadedAt,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -122,6 +124,7 @@ export const getMyTimesheets = async (req, res) => {
|
|||||||
fileSize: timesheets.fileSize,
|
fileSize: timesheets.fileSize,
|
||||||
year: timesheets.year,
|
year: timesheets.year,
|
||||||
month: timesheets.month,
|
month: timesheets.month,
|
||||||
|
isGenerated: timesheets.isGenerated,
|
||||||
uploadedAt: timesheets.uploadedAt,
|
uploadedAt: timesheets.uploadedAt,
|
||||||
})
|
})
|
||||||
.from(timesheets)
|
.from(timesheets)
|
||||||
@@ -171,6 +174,7 @@ export const getAllTimesheets = async (req, res) => {
|
|||||||
fileSize: timesheets.fileSize,
|
fileSize: timesheets.fileSize,
|
||||||
year: timesheets.year,
|
year: timesheets.year,
|
||||||
month: timesheets.month,
|
month: timesheets.month,
|
||||||
|
isGenerated: timesheets.isGenerated,
|
||||||
uploadedAt: timesheets.uploadedAt,
|
uploadedAt: timesheets.uploadedAt,
|
||||||
userId: timesheets.userId,
|
userId: timesheets.userId,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
|
|||||||
3
src/db/migrations/0005_add_is_generated_timesheets.sql
Normal file
3
src/db/migrations/0005_add_is_generated_timesheets.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add flag to mark system-generated timesheets
|
||||||
|
ALTER TABLE timesheets
|
||||||
|
ADD COLUMN IF NOT EXISTS is_generated BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -89,6 +89,17 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
|
-- Add is_generated flag to timesheets if not exists
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name='timesheets' AND column_name='is_generated'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE timesheets ADD COLUMN is_generated BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
-- Create indexes for better query performance
|
-- Create indexes for better query performance
|
||||||
CREATE INDEX IF NOT EXISTS idx_companies_created_at ON companies(created_at);
|
CREATE INDEX IF NOT EXISTS idx_companies_created_at ON companies(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_projects_company_id ON projects(company_id);
|
CREATE INDEX IF NOT EXISTS idx_projects_company_id ON projects(company_id);
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export const timesheets = pgTable('timesheets', {
|
|||||||
fileSize: integer('file_size').notNull(), // veľkosť súboru v bytoch
|
fileSize: integer('file_size').notNull(), // veľkosť súboru v bytoch
|
||||||
year: integer('year').notNull(), // rok (napr. 2024)
|
year: integer('year').notNull(), // rok (napr. 2024)
|
||||||
month: integer('month').notNull(), // mesiac (1-12)
|
month: integer('month').notNull(), // mesiac (1-12)
|
||||||
|
isGenerated: boolean('is_generated').default(false).notNull(), // či bol súbor vygenerovaný systémom
|
||||||
uploadedAt: timestamp('uploaded_at').defaultNow().notNull(),
|
uploadedAt: timestamp('uploaded_at').defaultNow().notNull(),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ router.get(
|
|||||||
timeTrackingController.getMonthlyTimeEntries
|
timeTrackingController.getMonthlyTimeEntries
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Generate monthly timesheet (XLSX)
|
||||||
|
router.post(
|
||||||
|
'/month/:year/:month/generate',
|
||||||
|
validateParams(
|
||||||
|
z.object({
|
||||||
|
year: z.string().regex(/^\d{4}$/, 'Rok musí byť 4-ciferné číslo'),
|
||||||
|
month: z.string().regex(/^(0?[1-9]|1[0-2])$/, 'Mesiac musí byť číslo 1-12'),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
timeTrackingController.generateMonthlyTimesheet
|
||||||
|
);
|
||||||
|
|
||||||
// Get monthly statistics
|
// Get monthly statistics
|
||||||
router.get(
|
router.get(
|
||||||
'/stats/monthly/:year/:month',
|
'/stats/monthly/:year/:month',
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { timeEntries, projects, todos, companies, users } from '../db/schema.js';
|
import { timeEntries, projects, todos, companies, users, timesheets } from '../db/schema.js';
|
||||||
import { eq, and, gte, lte, desc, sql } from 'drizzle-orm';
|
import { eq, and, gte, lte, desc } from 'drizzle-orm';
|
||||||
import { NotFoundError, BadRequestError } from '../utils/errors.js';
|
import { NotFoundError, BadRequestError } from '../utils/errors.js';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
// Helpers to normalize optional payload fields
|
// Helpers to normalize optional payload fields
|
||||||
const normalizeOptionalId = (value) => {
|
const normalizeOptionalId = (value) => {
|
||||||
@@ -18,6 +21,28 @@ const normalizeOptionalText = (value) => {
|
|||||||
return trimmed.length ? trimmed : null;
|
return trimmed.length ? trimmed : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (value) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (minutes) => {
|
||||||
|
if (!Number.isFinite(minutes)) return '';
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = Math.abs(minutes % 60);
|
||||||
|
return `${hours}:${String(mins).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a new time entry
|
* Start a new time entry
|
||||||
*/
|
*/
|
||||||
@@ -274,6 +299,210 @@ export const getMonthlyTimeEntries = async (userId, year, month) => {
|
|||||||
.orderBy(desc(timeEntries.startTime));
|
.orderBy(desc(timeEntries.startTime));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an XLSX timesheet for a given month and user.
|
||||||
|
*/
|
||||||
|
export const generateMonthlyTimesheet = async (userId, year, month) => {
|
||||||
|
const startDate = new Date(year, month - 1, 1);
|
||||||
|
const endDate = new Date(year, month, 0, 23, 59, 59, 999);
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.select({
|
||||||
|
username: users.username,
|
||||||
|
firstName: users.firstName,
|
||||||
|
lastName: users.lastName,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Používateľ nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await db
|
||||||
|
.select({
|
||||||
|
id: timeEntries.id,
|
||||||
|
startTime: timeEntries.startTime,
|
||||||
|
endTime: timeEntries.endTime,
|
||||||
|
duration: timeEntries.duration,
|
||||||
|
description: timeEntries.description,
|
||||||
|
projectName: projects.name,
|
||||||
|
todoTitle: todos.title,
|
||||||
|
companyName: companies.name,
|
||||||
|
})
|
||||||
|
.from(timeEntries)
|
||||||
|
.leftJoin(projects, eq(timeEntries.projectId, projects.id))
|
||||||
|
.leftJoin(todos, eq(timeEntries.todoId, todos.id))
|
||||||
|
.leftJoin(companies, eq(timeEntries.companyId, companies.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(timeEntries.userId, userId),
|
||||||
|
gte(timeEntries.startTime, startDate),
|
||||||
|
lte(timeEntries.startTime, endDate)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(timeEntries.startTime);
|
||||||
|
|
||||||
|
const completedEntries = entries.filter((entry) => entry.endTime && entry.duration !== null);
|
||||||
|
if (completedEntries.length === 0) {
|
||||||
|
throw new NotFoundError('Žiadne dokončené záznamy pre daný mesiac');
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalMinutes = 0;
|
||||||
|
const dailyTotals = {};
|
||||||
|
|
||||||
|
completedEntries.forEach((entry) => {
|
||||||
|
const minutes = entry.duration || 0;
|
||||||
|
totalMinutes += minutes;
|
||||||
|
const day = formatDate(entry.startTime);
|
||||||
|
dailyTotals[day] = (dailyTotals[day] || 0) + minutes;
|
||||||
|
});
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
workbook.creator = 'crm-server';
|
||||||
|
const worksheet = workbook.addWorksheet('Timesheet', {
|
||||||
|
views: [{ state: 'frozen', ySplit: 6 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ') || user.username;
|
||||||
|
const periodLabel = `${year}-${String(month).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
worksheet.getCell('A1').value = 'Timesheet';
|
||||||
|
worksheet.getCell('A1').font = { name: 'Calibri', size: 16, bold: true };
|
||||||
|
worksheet.mergeCells('A1:D1');
|
||||||
|
|
||||||
|
worksheet.getCell('A2').value = `Name: ${fullName}`;
|
||||||
|
worksheet.getCell('A3').value = `Period: ${periodLabel}`;
|
||||||
|
worksheet.getCell('A4').value = `Generated: ${new Date().toLocaleString()}`;
|
||||||
|
|
||||||
|
worksheet.columns = [
|
||||||
|
{ key: 'date', width: 12 },
|
||||||
|
{ key: 'project', width: 28 },
|
||||||
|
{ key: 'todo', width: 28 },
|
||||||
|
{ key: 'company', width: 24 },
|
||||||
|
{ key: 'description', width: 40 },
|
||||||
|
{ key: 'start', width: 12 },
|
||||||
|
{ key: 'end', width: 12 },
|
||||||
|
{ key: 'duration', width: 16 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const headerRowNumber = 6;
|
||||||
|
const headerRow = worksheet.getRow(headerRowNumber);
|
||||||
|
headerRow.values = [
|
||||||
|
'Date',
|
||||||
|
'Project',
|
||||||
|
'Todo',
|
||||||
|
'Company',
|
||||||
|
'Description',
|
||||||
|
'Start',
|
||||||
|
'End',
|
||||||
|
'Duration (h:mm)',
|
||||||
|
];
|
||||||
|
headerRow.font = { name: 'Calibri', size: 11, bold: true };
|
||||||
|
headerRow.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
||||||
|
headerRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8EEF5' } };
|
||||||
|
headerRow.height = 20;
|
||||||
|
|
||||||
|
completedEntries.forEach((entry) => {
|
||||||
|
worksheet.addRow({
|
||||||
|
date: formatDate(entry.startTime),
|
||||||
|
project: entry.projectName || '',
|
||||||
|
todo: entry.todoTitle || '',
|
||||||
|
company: entry.companyName || '',
|
||||||
|
description: entry.description || '',
|
||||||
|
start: formatTime(entry.startTime),
|
||||||
|
end: formatTime(entry.endTime),
|
||||||
|
duration: formatDuration(entry.duration),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Style body rows
|
||||||
|
worksheet.eachRow((row, rowNumber) => {
|
||||||
|
if (rowNumber >= headerRowNumber) {
|
||||||
|
row.alignment = { vertical: 'middle', wrapText: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let summaryStart = worksheet.lastRow.number + 2;
|
||||||
|
worksheet.getCell(`A${summaryStart}`).value = 'Daily totals';
|
||||||
|
worksheet.getCell(`A${summaryStart}`).font = { name: 'Calibri', size: 12, bold: true };
|
||||||
|
worksheet.mergeCells(`A${summaryStart}:B${summaryStart}`);
|
||||||
|
|
||||||
|
const dailyHeaderRowNumber = summaryStart + 1;
|
||||||
|
const dailyHeaderRow = worksheet.getRow(dailyHeaderRowNumber);
|
||||||
|
dailyHeaderRow.values = ['Date', 'Total (h:mm)'];
|
||||||
|
dailyHeaderRow.font = { name: 'Calibri', size: 11, bold: true };
|
||||||
|
dailyHeaderRow.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
dailyHeaderRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8EEF5' } };
|
||||||
|
|
||||||
|
Object.keys(dailyTotals)
|
||||||
|
.sort()
|
||||||
|
.forEach((day) => {
|
||||||
|
worksheet.addRow([day, formatDuration(dailyTotals[day])]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const overallRow = worksheet.addRow(['Overall total', formatDuration(totalMinutes)]);
|
||||||
|
overallRow.font = { name: 'Calibri', size: 11, bold: true };
|
||||||
|
|
||||||
|
const uploadsDir = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'uploads',
|
||||||
|
'timesheets',
|
||||||
|
userId.toString(),
|
||||||
|
year.toString(),
|
||||||
|
month.toString()
|
||||||
|
);
|
||||||
|
await fs.mkdir(uploadsDir, { recursive: true });
|
||||||
|
|
||||||
|
const filename = `timesheet-${periodLabel}-${Date.now()}.xlsx`;
|
||||||
|
const filePath = path.join(uploadsDir, filename);
|
||||||
|
let savedFilePath = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await workbook.xlsx.writeFile(filePath);
|
||||||
|
savedFilePath = filePath;
|
||||||
|
const { size: fileSize } = await fs.stat(filePath);
|
||||||
|
|
||||||
|
const [newTimesheet] = await db
|
||||||
|
.insert(timesheets)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
fileName: filename,
|
||||||
|
filePath,
|
||||||
|
fileType: 'xlsx',
|
||||||
|
fileSize,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
isGenerated: true,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return {
|
||||||
|
timesheet: {
|
||||||
|
id: newTimesheet.id,
|
||||||
|
fileName: newTimesheet.fileName,
|
||||||
|
fileType: newTimesheet.fileType,
|
||||||
|
fileSize: newTimesheet.fileSize,
|
||||||
|
year: newTimesheet.year,
|
||||||
|
month: newTimesheet.month,
|
||||||
|
isGenerated: newTimesheet.isGenerated,
|
||||||
|
uploadedAt: newTimesheet.uploadedAt,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
totalMinutes,
|
||||||
|
totalFormatted: formatDuration(totalMinutes),
|
||||||
|
daily: dailyTotals,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (savedFilePath) {
|
||||||
|
await fs.unlink(savedFilePath).catch(() => {});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update time entry
|
* Update time entry
|
||||||
*/
|
*/
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user