Files
eatme/gscript/NewsService_Roles.js
Michal Pemcak f917ed06a8 Add shift planning, month closure + payroll, and a dark modern redesign
Ports two modules from the friend's Google Apps Script build (kept as
reference in gscript/) onto the TypeScript stack, rewritten cleanly against
this app's own data model rather than copied 1:1:

- Shift planning: weekly template (Sun-Thu evening, Fri/Sat two slots),
  lazy idempotent generation per period (no cron needed), employee
  signup/cancel with collision + capacity checks, admin calendar view with
  slot editing and manual assignment.
- Month closure + payroll: employee confirms the month (blocked while a
  shift is still open), admin locks and finalizes pay (base hours * rate +
  tips/bonus/other), reopen to undo a premature lock, mark paid. Pay rates
  are versioned by date, defaulting the first-ever rate to apply
  retroactively to the employee's whole history.
- A shift left open more than 12h (forgotten clock-out) is auto-closed at
  clock_in + 12h, checked lazily on read instead of a background job.
- Full dark, sharp-edged modern restyle (theme.css replaces tui.css) with
  an amber accent, keeping every existing class name so no component
  logic needed to change.

Backend test coverage (jest) for all three workflows: shift planning,
closure/payroll, and the forgotten-clock-out auto-close.
2026-08-16 18:41:11 +02:00

1576 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
EATME PORTÁL NEWS SERVICE
Role:
- EMPLOYEE: čte ALL + EMPLOYEE
- MANAGER: čte ALL + MANAGER, může spravovat aktuality
- ACCOUNTANT: čte ALL + ACCOUNTANT
- ADMIN: čte všechny aktuality, může je spravovat
Využívá existující:
- NEWS
- NEWS_READS
============================================================ */
/* ============================================================
SETUP
============================================================ */
function setupNews() {
ensureNewsSheet_(
CFG.SHEETS.NEWS,
HEADERS.NEWS
);
ensureNewsSheet_(
CFG.SHEETS.NEWS_READS,
HEADERS.NEWS_READS
);
Logger.log(
'News setup OK'
);
}
/* ============================================================
AKTUALITY PRO PŘIHLÁŠENÉHO UŽIVATELE
============================================================ */
function getMyNews(token) {
const user =
requireUser_(
token
);
const role =
String(
user.role ||
''
).toUpperCase();
const now =
new Date();
const newsRows =
rows_(
CFG.SHEETS.NEWS
);
const readRows =
rows_(
CFG.SHEETS.NEWS_READS
)
.filter(
function(row) {
return (
String(
row.user_id
) ===
String(
user.user_id
)
);
}
);
const readsByNews =
{};
readRows.forEach(
function(row) {
readsByNews[
String(
row.news_id
)
] =
row;
}
);
return newsRows
.filter(
function(row) {
if (
!newsBool_(
row.active
)
) {
return false;
}
const target =
String(
row.target_role ||
'ALL'
)
.trim()
.toUpperCase();
/*
* ADMIN vidí všechno.
* Ostatní role pouze ALL + svou roli.
*/
if (
role !==
'ADMIN' &&
target !==
'ALL' &&
target !==
role
) {
return false;
}
const from =
newsDate_(
row.published_from
);
const to =
newsDate_(
row.published_to
);
if (
from &&
from.getTime() >
now.getTime()
) {
return false;
}
if (
to &&
to.getTime() <
now.getTime()
) {
return false;
}
return true;
}
)
.map(
function(row) {
const read =
readsByNews[
String(
row.news_id
)
] ||
null;
return {
news_id:
String(
row.news_id ||
''
),
title:
String(
row.title ||
''
),
content:
String(
row.content ||
''
),
category:
String(
row.category ||
''
),
target_role:
String(
row.target_role ||
'ALL'
),
published_from:
row.published_from ||
'',
published_to:
row.published_to ||
'',
require_confirmation:
newsBool_(
row.require_confirmation
),
read_at:
read
? read.read_at ||
''
: '',
confirmed_at:
read
? read.confirmed_at ||
''
: '',
created_at:
row.created_at ||
''
};
}
)
.sort(
function(a,b) {
const aImportant =
String(
a.category
).toUpperCase() ===
'DŮLEŽITÉ'
? 1
: 0;
const bImportant =
String(
b.category
).toUpperCase() ===
'DŮLEŽITÉ'
? 1
: 0;
if (
aImportant !==
bImportant
) {
return (
bImportant -
aImportant
);
}
return (
newsTime_(
b.created_at
) -
newsTime_(
a.created_at
)
);
}
);
}
/* ============================================================
PŘEČTENO / POTVRZENO
============================================================ */
function markNewsRead(
token,
newsId,
confirm
) {
const user =
requireUser_(
token
);
const news =
findOne_(
CFG.SHEETS.NEWS,
function(row) {
return (
String(
row.news_id
) ===
String(
newsId
)
);
}
);
if (
!news
) {
throw new Error(
'Aktualita nebyla nalezena.'
);
}
const existing =
findOne_(
CFG.SHEETS.NEWS_READS,
function(row) {
return (
String(
row.news_id
) ===
String(
newsId
) &&
String(
row.user_id
) ===
String(
user.user_id
)
);
}
);
const stamp =
now_();
if (
existing
) {
const update =
{};
if (
!existing.read_at
) {
update.read_at =
stamp;
}
if (
confirm
) {
update.confirmed_at =
stamp;
}
if (
Object.keys(
update
).length
) {
updateBy_(
CFG.SHEETS.NEWS_READS,
'news_read_id',
existing.news_read_id,
update
);
}
} else {
append_(
CFG.SHEETS.NEWS_READS,
{
news_read_id:
uuid_(
'NR'
),
news_id:
newsId,
user_id:
user.user_id,
read_at:
stamp,
confirmed_at:
confirm
? stamp
: ''
}
);
}
return {
ok:true
};
}
/* ============================================================
HROMADNÉ OZNAČENÍ AKTUALIT JAKO PŘEČTENÉ
Jeden request pro všechny zobrazené aktuality.
============================================================ */
function markNewsListRead(
token,
newsIds
) {
const user =
requireUser_(
token
);
newsIds =
Array.isArray(
newsIds
)
? newsIds
: [];
const uniqueIds =
Array.from(
new Set(
newsIds
.map(
function(id) {
return String(
id ||
''
).trim();
}
)
.filter(
Boolean
)
)
);
if (
!uniqueIds.length
) {
return {
ok:true
};
}
const existingRows =
rows_(
CFG.SHEETS.NEWS_READS
)
.filter(
function(row) {
return (
String(
row.user_id
) ===
String(
user.user_id
)
);
}
);
const existingByNews =
{};
existingRows.forEach(
function(row) {
existingByNews[
String(
row.news_id
)
] =
row;
}
);
const stamp =
now_();
uniqueIds.forEach(
function(newsId) {
const existing =
existingByNews[
newsId
];
if (
existing
) {
if (
!existing.read_at
) {
updateBy_(
CFG.SHEETS.NEWS_READS,
'news_read_id',
existing.news_read_id,
{
read_at:
stamp
}
);
}
} else {
append_(
CFG.SHEETS.NEWS_READS,
{
news_read_id:
uuid_(
'NR'
),
news_id:
newsId,
user_id:
user.user_id,
read_at:
stamp,
confirmed_at:
''
}
);
}
}
);
return {
ok:true
};
}
/* ============================================================
ADMIN / MANAGER SEZNAM AKTUALIT
============================================================ */
function getAdminNews(token) {
requireUser_(
token,
[
'ADMIN',
'MANAGER'
]
);
const newsRows =
rows_(
CFG.SHEETS.NEWS
);
const readRows =
rows_(
CFG.SHEETS.NEWS_READS
);
const users =
rows_(
CFG.SHEETS.USERS
)
.filter(
function(user) {
return newsBool_(
user.active
);
}
);
return newsRows
.map(
function(row) {
const newsId =
String(
row.news_id ||
''
);
const target =
String(
row.target_role ||
'ALL'
).toUpperCase();
const audience =
users.filter(
function(user) {
return (
target ===
'ALL' ||
target ===
String(
user.role ||
''
).toUpperCase()
);
}
);
const reads =
readRows.filter(
function(read) {
return (
String(
read.news_id
) ===
newsId
);
}
);
return {
news_id:
newsId,
title:
String(
row.title ||
''
),
content:
String(
row.content ||
''
),
category:
String(
row.category ||
''
),
target_role:
String(
row.target_role ||
'ALL'
),
published_from:
row.published_from ||
'',
published_to:
row.published_to ||
'',
require_confirmation:
newsBool_(
row.require_confirmation
),
active:
newsBool_(
row.active
),
created_at:
row.created_at ||
'',
audience_count:
audience.length,
read_count:
reads.filter(
function(read) {
return !!read.read_at;
}
).length,
confirmed_count:
reads.filter(
function(read) {
return !!read.confirmed_at;
}
).length
};
}
)
.sort(
function(a,b) {
return (
newsTime_(
b.created_at
) -
newsTime_(
a.created_at
)
);
}
);
}
/* ============================================================
ADMIN / MANAGER ULOŽIT AKTUALITU
============================================================ */
function saveAdminNews(
token,
payload
) {
const user =
requireUser_(
token,
[
'ADMIN',
'MANAGER'
]
);
payload =
payload ||
{};
const title =
String(
payload.title ||
''
).trim();
const content =
String(
payload.content ||
''
).trim();
if (
!title
) {
throw new Error(
'Vyplň nadpis aktuality.'
);
}
if (
!content
) {
throw new Error(
'Vyplň text aktuality.'
);
}
const allowedTargets =
[
'ALL',
'EMPLOYEE',
'MANAGER',
'ACCOUNTANT',
'ADMIN'
];
const targetRole =
String(
payload.target_role ||
'ALL'
).toUpperCase();
if (
!allowedTargets.includes(
targetRole
)
) {
throw new Error(
'Neplatná cílová role.'
);
}
const newsId =
String(
payload.news_id ||
''
).trim();
const data =
{
title:
title,
content:
content,
category:
String(
payload.category ||
'Provoz'
),
company_id:
String(
payload.company_id ||
''
),
location_id:
String(
payload.location_id ||
''
),
target_role:
targetRole,
published_from:
payload.published_from ||
now_(),
published_to:
payload.published_to ||
'',
require_confirmation:
!!payload.require_confirmation,
active:
payload.active !==
false
};
if (
newsId
) {
const old =
findOne_(
CFG.SHEETS.NEWS,
function(row) {
return (
String(
row.news_id
) ===
newsId
);
}
);
if (
!old
) {
throw new Error(
'Aktualita nebyla nalezena.'
);
}
updateBy_(
CFG.SHEETS.NEWS,
'news_id',
newsId,
data
);
audit_(
user.user_id,
'NEWS_UPDATED',
'NEWS',
newsId,
old,
data
);
return {
ok:true,
news_id:newsId
};
}
const newId =
uuid_(
'NEWS'
);
append_(
CFG.SHEETS.NEWS,
{
news_id:
newId,
title:
data.title,
content:
data.content,
category:
data.category,
company_id:
data.company_id,
location_id:
data.location_id,
target_role:
data.target_role,
published_from:
data.published_from,
published_to:
data.published_to,
require_confirmation:
data.require_confirmation,
active:
data.active,
created_by:
user.user_id,
created_at:
now_()
}
);
audit_(
user.user_id,
'NEWS_CREATED',
'NEWS',
newId,
'',
data
);
return {
ok:true,
news_id:newId
};
}
/* ============================================================
SKRÝT / ZOBRAZIT
============================================================ */
function setAdminNewsActive(
token,
newsId,
active
) {
const user =
requireUser_(
token,
[
'ADMIN',
'MANAGER'
]
);
const news =
findOne_(
CFG.SHEETS.NEWS,
function(row) {
return (
String(
row.news_id
) ===
String(
newsId
)
);
}
);
if (
!news
) {
throw new Error(
'Aktualita nebyla nalezena.'
);
}
updateBy_(
CFG.SHEETS.NEWS,
'news_id',
newsId,
{
active:
!!active
}
);
audit_(
user.user_id,
active
? 'NEWS_SHOWN'
: 'NEWS_HIDDEN',
'NEWS',
newsId,
news.active,
!!active
);
return {
ok:true
};
}
/* ============================================================
ADMIN KDO PŘEČETL / POTVRDIL
============================================================ */
function getAdminNewsReaders(
token,
newsId
) {
requireUser_(
token,
[
'ADMIN',
'MANAGER'
]
);
const news =
findOne_(
CFG.SHEETS.NEWS,
function(row) {
return (
String(
row.news_id
) ===
String(
newsId
)
);
}
);
if (
!news
) {
throw new Error(
'Aktualita nebyla nalezena.'
);
}
const target =
String(
news.target_role ||
'ALL'
).toUpperCase();
const users =
rows_(
CFG.SHEETS.USERS
)
.filter(
function(user) {
if (
!newsBool_(
user.active
)
) {
return false;
}
return (
target ===
'ALL' ||
target ===
String(
user.role ||
''
).toUpperCase()
);
}
);
const employees =
rows_(
CFG.SHEETS.EMPLOYEES
);
const reads =
rows_(
CFG.SHEETS.NEWS_READS
)
.filter(
function(row) {
return (
String(
row.news_id
) ===
String(
newsId
)
);
}
);
return users
.map(
function(user) {
const read =
reads.find(
function(row) {
return (
String(
row.user_id
) ===
String(
user.user_id
)
);
}
) ||
null;
const employee =
employees.find(
function(row) {
return (
String(
row.employee_id
) ===
String(
user.employee_id ||
''
)
);
}
) ||
null;
return {
user_id:
String(
user.user_id ||
''
),
name:
employee
? (
String(
employee.first_name ||
''
) +
' ' +
String(
employee.last_name ||
''
)
).trim()
: String(
user.email ||
''
),
email:
String(
user.email ||
''
),
role:
String(
user.role ||
''
),
read_at:
read
? read.read_at ||
''
: '',
confirmed_at:
read
? read.confirmed_at ||
''
: ''
};
}
);
}
/* ============================================================
HELPERS
============================================================ */
function newsBool_(value) {
return (
value ===
true ||
String(
value
).toUpperCase() ===
'TRUE' ||
String(
value
) ===
'1'
);
}
function newsDate_(value) {
if (
!value
) {
return null;
}
const date =
value instanceof Date
? value
: new Date(
value
);
return isNaN(
date.getTime()
)
? null
: date;
}
function newsTime_(value) {
const date =
newsDate_(
value
);
return date
? date.getTime()
: 0;
}
function ensureNewsSheet_(
sheetName,
headers
) {
try {
const existing =
sh_(
sheetName
);
if (
existing
) {
return existing;
}
}
catch(error) {
/*
* List ještě neexistuje.
*/
}
let spreadsheet =
null;
try {
spreadsheet =
sh_(
CFG.SHEETS.USERS
).getParent();
}
catch(error) {
/*
* Fallback níže.
*/
}
if (
!spreadsheet
) {
spreadsheet =
SpreadsheetApp
.getActiveSpreadsheet();
}
if (
!spreadsheet
) {
throw new Error(
'Nepodařilo se otevřít databázový spreadsheet.'
);
}
let sheet =
spreadsheet.getSheetByName(
sheetName
);
if (
!sheet
) {
sheet =
spreadsheet.insertSheet(
sheetName
);
sheet
.getRange(
1,
1,
1,
headers.length
)
.setValues(
[
headers
]
);
sheet.setFrozenRows(
1
);
}
return sheet;
}