excel preview & file handling

This commit is contained in:
richardtekula
2025-11-24 10:18:28 +01:00
parent dfcf8056f3
commit 7fd6b9e742
12 changed files with 1336 additions and 18 deletions

View File

@@ -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 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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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

View File

@@ -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,

View 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;

View File

@@ -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);

View File

@@ -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(),

View File

@@ -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',

View File

@@ -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
*/ */