Autosave: 20260217-021237

This commit is contained in:
Flatlogic Bot 2026-02-17 02:12:37 +00:00
parent cc52d3dc41
commit 9a3496f9fd
26 changed files with 904 additions and 999 deletions

View File

@ -0,0 +1,100 @@
const db = require('../models');
const FileDBApi = require('./file');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
module.exports = class LoginBackgroundsDBApi {
static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const record = await db.login_backgrounds.create(
{
month: data.month,
importHash: data.importHash,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction }
);
await FileDBApi.replaceRelationFiles(
{
belongsTo: 'login_backgrounds',
belongsToColumn: 'image',
belongsToId: record.id,
},
data.image,
options
);
return record;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const record = await db.login_backgrounds.findByPk(id, { transaction });
if (!record) return null;
const payload = {
updatedById: currentUser.id,
};
if (data.month !== undefined) payload.month = data.month;
await record.update(payload, { transaction });
await FileDBApi.replaceRelationFiles(
{
belongsTo: 'login_backgrounds',
belongsToColumn: 'image',
belongsToId: record.id,
},
data.image,
options
);
return record;
}
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const record = await db.login_backgrounds.findOne({ where, transaction });
if (!record) return null;
const output = record.get({ plain: true });
output.image = await record.getImage({ transaction });
return output;
}
static async findAll(filter, options) {
const transaction = (options && options.transaction) || undefined;
const include = [
{
model: db.file,
as: 'image',
},
];
const { rows, count } = await db.login_backgrounds.findAndCountAll({
where: {},
include,
order: [['month', 'ASC']],
transaction,
});
return { rows, count };
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const record = await db.login_backgrounds.findByPk(id, { transaction });
if (!record) return null;
await record.destroy({ transaction });
return record;
}
};

View File

@ -0,0 +1,36 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.addColumn('yearly_leave_summaries', 'opening_balance', {
type: Sequelize.DataTypes.DECIMAL,
allowNull: true,
defaultValue: 0,
}, { transaction });
await queryInterface.addColumn('yearly_leave_summaries', 'ending_balance', {
type: Sequelize.DataTypes.DECIMAL,
allowNull: true,
defaultValue: 0,
}, { transaction });
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.removeColumn('yearly_leave_summaries', 'opening_balance', { transaction });
await queryInterface.removeColumn('yearly_leave_summaries', 'ending_balance', { transaction });
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
}
};

View File

@ -0,0 +1,45 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('login_backgrounds', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
month: {
type: Sequelize.INTEGER,
allowNull: false,
unique: true,
comment: '0: Default, 1-12: Specific Month',
},
importHash: {
type: Sequelize.STRING(255),
allowNull: true,
unique: true,
},
createdAt: {
type: Sequelize.DATE,
},
updatedAt: {
type: Sequelize.DATE,
},
createdById: {
type: Sequelize.UUID,
references: {
model: 'users',
key: 'id',
},
},
updatedById: {
type: Sequelize.UUID,
references: {
model: 'users',
key: 'id',
},
},
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('login_backgrounds');
},
};

View File

@ -0,0 +1,51 @@
module.exports = function(sequelize, DataTypes) {
const loginBackgrounds = sequelize.define(
'login_backgrounds',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
month: {
type: DataTypes.INTEGER,
allowNull: false,
unique: true,
validate: {
min: 0,
max: 12,
},
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: false,
tableName: 'login_backgrounds',
},
);
loginBackgrounds.associate = (db) => {
db.login_backgrounds.belongsTo(db.users, {
as: 'createdBy',
});
db.login_backgrounds.belongsTo(db.users, {
as: 'updatedBy',
});
db.login_backgrounds.hasMany(db.file, {
as: 'image',
foreignKey: 'belongsToId',
constraints: false,
scope: {
belongsTo: 'login_backgrounds',
belongsToColumn: 'image',
},
});
};
return loginBackgrounds;
};

View File

@ -77,6 +77,16 @@ vacation_pay_paid_amount: {
},
opening_balance: {
type: DataTypes.DECIMAL,
allowNull: true,
},
ending_balance: {
type: DataTypes.DECIMAL,
allowNull: true,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
@ -134,6 +144,4 @@ vacation_pay_paid_amount: {
return yearly_leave_summaries;
};
};

View File

@ -1,4 +1,3 @@
const express = require('express');
const cors = require('cors');
const app = express();
@ -41,6 +40,7 @@ const office_calendar_eventsRoutes = require('./routes/office_calendar_events');
const approval_tasksRoutes = require('./routes/approval_tasks');
const appSettingsRoutes = require('./routes/app_settings');
const loginBackgroundsRoutes = require('./routes/login_backgrounds');
const checkLockout = require('./middlewares/lockout');
@ -128,6 +128,7 @@ app.use('/api/approval_tasks', authAndLockout, approval_tasksRoutes);
// App Settings (Admin only basically, but handled in route).
// IMPORTANT: Do NOT apply lockout middleware here, otherwise admin can't unlock!
app.use('/api/app_settings', auth, appSettingsRoutes);
app.use('/api/login_backgrounds', loginBackgroundsRoutes);
app.use(
'/api/openai',
@ -173,4 +174,4 @@ db.sequelize.sync().then(function () {
});
});
module.exports = app;
module.exports = app;

View File

@ -0,0 +1,34 @@
const express = require('express');
const LoginBackgroundsService = require('../services/login_backgrounds');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
const passport = require('passport');
// Public route to get current background
router.get('/current', wrapAsync(async (req, res) => {
const record = await LoginBackgroundsService.findCurrent();
res.send(record);
}));
// Admin routes
router.get('/', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => {
const payload = await LoginBackgroundsService.listAll(req.currentUser);
res.send(payload);
}));
router.post('/', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => {
await LoginBackgroundsService.create(req.body, req.currentUser);
res.sendStatus(200);
}));
router.put('/:id', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => {
await LoginBackgroundsService.update(req.params.id, req.body, req.currentUser);
res.sendStatus(200);
}));
router.delete('/:id', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => {
await LoginBackgroundsService.remove(req.params.id, req.currentUser);
res.sendStatus(200);
}));
module.exports = router;

View File

@ -308,8 +308,19 @@ router.get('/', wrapAsync(async (req, res) => {
const filetype = req.query.filetype
const currentUser = req.currentUser;
let filter = { ...req.query };
if (req.query.filter && typeof req.query.filter === 'string') {
try {
const parsed = JSON.parse(req.query.filter);
filter = { ...filter, ...parsed };
} catch (e) {
console.error('Failed to parse filter query param:', e);
}
}
const payload = await Yearly_leave_summariesDBApi.findAll(
req.query, { currentUser }
filter, { currentUser }
);
if (filetype && filetype === 'csv') {
const fields = ['id',
@ -360,8 +371,19 @@ router.get('/', wrapAsync(async (req, res) => {
router.get('/count', wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
let filter = { ...req.query };
if (req.query.filter && typeof req.query.filter === 'string') {
try {
const parsed = JSON.parse(req.query.filter);
filter = { ...filter, ...parsed };
} catch (e) {
console.error('Failed to parse filter query param:', e);
}
}
const payload = await Yearly_leave_summariesDBApi.findAll(
req.query,
filter,
null,
{ countOnly: true, currentUser }
);

View File

@ -0,0 +1,59 @@
const db = require('../db/models');
const LoginBackgroundsDBApi = require('../db/api/login_backgrounds');
module.exports = class LoginBackgroundsService {
static async findCurrent() {
const currentMonth = new Date().getMonth() + 1; // 1-12
let record = await LoginBackgroundsDBApi.findBy({ month: currentMonth });
if (!record) {
record = await LoginBackgroundsDBApi.findBy({ month: 0 }); // Default
}
if (record && record.image && record.image.length > 0) {
return { imageUrl: record.image[0].publicUrl };
}
return { imageUrl: null };
}
static async listAll(currentUser) {
const result = await LoginBackgroundsDBApi.findAll({}, { currentUser });
return result;
}
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const record = await LoginBackgroundsDBApi.create(data, { currentUser, transaction });
await transaction.commit();
return record;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(id, data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const record = await LoginBackgroundsDBApi.update(id, data, { currentUser, transaction });
await transaction.commit();
return record;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await LoginBackgroundsDBApi.remove(id, { currentUser, transaction });
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};

View File

@ -67,6 +67,7 @@ module.exports = class Pto_journal_entriesService {
static async bulkAdjust(data, currentUser) {
const { userIds, entry_type, leave_bucket, amount_hours, amount_days, memo } = data;
const calendar_year = data.calendar_year || new Date().getFullYear();
const transaction = await db.sequelize.transaction();
try {
const entries = userIds.map(userId => ({
@ -79,13 +80,38 @@ module.exports = class Pto_journal_entriesService {
entered_byId: currentUser.id,
entered_at: new Date(),
posting_status: 'posted',
calendar_year: new Date().getFullYear(),
calendar_year,
effective_at: new Date(),
counts_against_balance: true, // Assuming adjustments usually count
}));
await db.pto_journal_entries.bulkCreate(entries, { transaction });
// Update Yearly Leave Summaries
// We assume adjustments primarily affect 'regular_pto' available balance for now
if (leave_bucket === 'regular_pto' || !leave_bucket) {
for (const userId of userIds) {
const summary = await db.yearly_leave_summaries.findOne({
where: { userId, calendar_year },
transaction
});
if (summary) {
let adjustment = Number(amount_days || 0);
if (entry_type === 'debit_manual_adjustment') {
adjustment = -adjustment;
}
const newBalance = Number(summary.pto_available_days || 0) + adjustment;
await summary.update({ pto_available_days: newBalance }, { transaction });
} else {
// If summary doesn't exist, we skip for now or could create it.
// Given previous tasks, we assume summaries exist or are created on user creation.
console.warn(`Yearly leave summary not found for user ${userId} year ${calendar_year}`);
}
}
}
await transaction.commit();
} catch (error) {
await transaction.rollback();

View File

@ -1,11 +1,13 @@
const db = require('../db/models');
const UsersDBApi = require('../db/api/users');
const YearlyLeaveSummariesDBApi = require('../db/api/yearly_leave_summaries');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
const moment = require('moment');
const InvitationEmail = require('./email/list/invitation');
@ -26,7 +28,7 @@ module.exports = class UsersService {
'iam.errors.userAlreadyExists',
);
} else {
await UsersDBApi.create(
const newUser = await UsersDBApi.create(
{data},
{
@ -34,6 +36,22 @@ module.exports = class UsersService {
transaction,
},
);
// Create yearly leave summary for the current year
const currentYear = moment().year();
await YearlyLeaveSummariesDBApi.create({
calendar_year: currentYear,
user: newUser.id,
pto_available_days: data.paid_pto_per_year || 20,
pto_pending_days: 0,
pto_scheduled_days: 0,
pto_taken_days: 0,
medical_taken_days: 0,
bereavement_taken_days: 0,
time_in_lieu_available_days: 0,
vacation_pay_paid_amount: 0
}, { transaction, currentUser });
emailsToInvite.push(email);
}
} else {
@ -86,6 +104,10 @@ module.exports = class UsersService {
currentUser: req.currentUser
});
// TODO: Bulk import likely also needs to create yearly summaries,
// but sticking to the request scope for single user creation first.
// If requested, I can add it here too by iterating over created users.
emailsToInvite = results.map((result) => result.email);
await transaction.commit();
@ -166,6 +188,4 @@ module.exports = class UsersService {
throw error;
}
}
};
};

View File

@ -1,4 +1,4 @@
import { Children, cloneElement, ReactElement, ReactNode } from 'react'
import { Children, cloneElement, ReactElement, ReactNode, isValidElement } from 'react'
import BaseIcon from './BaseIcon'
import { useAppSelector } from '../stores/hooks';
@ -17,12 +17,24 @@ type Props = {
websiteBg?: boolean
}
const extractNameFromChildren = (children: ReactNode): string => {
const names: string[] = [];
Children.forEach(children, (child) => {
if (isValidElement(child) && (child.props as any).name) {
names.push((child.props as any).name);
}
});
return names.join(', ');
}
const FormField = ({ icons = [], ...props }: Props) => {
const childrenCount = Children.count(props.children)
const bgColor = useAppSelector((state) => state.style.cardsColor);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const corners = useAppSelector((state) => state.style.corners);
const bgWebsiteColor = useAppSelector((state) => state.style.bgLayoutColor);
const showDevInfo = useAppSelector((state) => state.style.showDevInfo);
let elementWrapperClass = ''
switch (childrenCount) {
@ -43,15 +55,24 @@ const FormField = ({ icons = [], ...props }: Props) => {
props.borderButtom ? `border-0 border-b ${props.diversity ? "border-gray-400" : " placeholder-white border-gray-300/10 border-white "} rounded-none focus:ring-0` : '',
].join(' ');
const devFieldName = props.labelFor || extractNameFromChildren(props.children);
return (
<div className="mb-6 last:mb-0">
{props.label && (
<label
htmlFor={props.labelFor}
className={`block font-bold mb-2 ${props.labelFor ? 'cursor-pointer' : ''}`}
>
{props.label}
</label>
<div className="flex justify-between items-center mb-2">
<label
htmlFor={props.labelFor}
className={`block font-bold ${props.labelFor ? 'cursor-pointer' : ''}`}
>
{props.label}
</label>
{showDevInfo && devFieldName && (
<span className="text-xs text-blue-600 bg-blue-100 px-2 py-0.5 rounded font-mono ml-2">
DB: {devFieldName}
</span>
)}
</div>
)}
<div className={`${elementWrapperClass}`}>
{Children.map(props.children, (child: ReactElement, index) => (
@ -77,4 +98,4 @@ const FormField = ({ icons = [], ...props }: Props) => {
)
}
export default FormField
export default FormField

View File

@ -207,6 +207,30 @@ export const loadColumns = async (
},
{
field: 'opening_balance',
headerName: 'Opening Balance',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'ending_balance',
headerName: 'Ending Balance',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'actions',
type: 'actions',
@ -231,4 +255,4 @@ export const loadColumns = async (
},
},
];
};
};

View File

@ -32,14 +32,6 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
permissions: 'READ_PERMISSIONS'
},
{
href: '/holiday_calendars/holiday_calendars-list',
label: 'Holiday calendars',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCalendarStar' in icon ? icon['mdiCalendarStar' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_HOLIDAY_CALENDARS'
},
{
href: '/holidays/holidays-list',
label: 'Holidays',
@ -64,6 +56,12 @@ const menuAside: MenuAsideItem[] = [
icon: 'mdiBookOpenPageVariant' in icon ? icon['mdiBookOpenPageVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PTO_JOURNAL_ENTRIES'
},
{
href: '/pto_journal_entries/balance-adjustment',
label: 'Balance Adjustment',
icon: icon.mdiScaleBalance,
permissions: 'READ_PTO_JOURNAL_ENTRIES'
},
{
href: '/yearly_leave_summaries/yearly_leave_summaries-list',
label: 'Yearly leave summaries',

View File

@ -83,7 +83,7 @@ const Dashboard = () => {
<select
value={selectedYear}
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
className="px-2 py-1 border rounded dark:bg-dark-800 dark:border-dark-700"
className="pl-2 pr-10 py-1 border rounded dark:bg-dark-800 dark:border-dark-700 dark:text-white"
>
{years.map(y => (
<option key={y} value={y}>{y}</option>

View File

@ -55,7 +55,7 @@ const EmployeeSummary = () => {
<select
value={selectedYear}
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
className="px-2 py-1 border rounded dark:bg-dark-800 dark:border-dark-700"
className="pl-2 pr-10 py-1 border rounded dark:bg-dark-800 dark:border-dark-700"
>
{years.map(y => (
<option key={y} value={y}>{y}</option>
@ -121,4 +121,4 @@ EmployeeSummary.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default EmployeeSummary
export default EmployeeSummary

View File

@ -1,9 +1,6 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement, useEffect, useState } from 'react'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
@ -28,186 +25,19 @@ import {RichTextField} from "../../components/RichTextField";
import { update, fetch } from '../../stores/holidays/holidaysSlice'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
import moment from 'moment';
const EditHolidaysPage = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const initVals = {
holiday_calendar: null,
'name': '',
starts_at: new Date(),
ends_at: new Date(),
name: '',
starts_at: '',
ends_at: '',
is_company_holiday: false,
notes: '',
}
const [initialValues, setInitialValues] = useState(initVals)
@ -217,25 +47,33 @@ const EditHolidaysPage = () => {
const { id } = router.query
useEffect(() => {
dispatch(fetch({ id: id }))
}, [id])
useEffect(() => {
if (typeof holidays === 'object') {
setInitialValues(holidays)
if (id) {
dispatch(fetch({ id: id }))
}
}, [holidays])
}, [id, dispatch])
useEffect(() => {
if (typeof holidays === 'object') {
if (holidays && typeof holidays === 'object') {
const newInitialVal = {...initVals};
Object.keys(initVals).forEach(el => newInitialVal[el] = (holidays)[el])
// Map fields and format dates
Object.keys(initVals).forEach(el => {
if (el === 'starts_at' || el === 'ends_at') {
newInitialVal[el] = holidays[el] ? moment(holidays[el]).format('YYYY-MM-DD') : '';
} else {
newInitialVal[el] = holidays[el];
}
})
setInitialValues(newInitialVal);
}
}, [holidays])
const handleSubmit = async (data) => {
await dispatch(update({ id: id, data }))
const payload = {
...data,
starts_at: data.starts_at ? `${data.starts_at}T00:00:00` : null,
ends_at: data.ends_at ? `${data.ends_at}T23:59:59` : null,
};
await dispatch(update({ id: id, data: payload }))
await router.push('/holidays/holidays-list')
}
@ -256,27 +94,6 @@ const EditHolidaysPage = () => {
>
<Form>
<FormField label='HolidayCalendar' labelFor='holiday_calendar'>
<Field
name='holiday_calendar'
@ -284,44 +101,10 @@ const EditHolidaysPage = () => {
component={SelectField}
options={initialValues.holiday_calendar}
itemRef={'holiday_calendars'}
showField={'name'}
></Field>
</FormField>
<FormField
label="Name"
>
@ -330,137 +113,27 @@ const EditHolidaysPage = () => {
placeholder="Name"
/>
</FormField>
<FormField
label="StartsAt"
>
<DatePicker
dateFormat="yyyy-MM-dd hh:mm"
showTimeSelect
selected={initialValues.starts_at ?
new Date(
dayjs(initialValues.starts_at).format('YYYY-MM-DD hh:mm'),
) : null
}
onChange={(date) => setInitialValues({...initialValues, 'starts_at': date})}
<Field
type="date"
name="starts_at"
placeholder="StartsAt"
/>
</FormField>
<FormField
label="EndsAt"
>
<DatePicker
dateFormat="yyyy-MM-dd hh:mm"
showTimeSelect
selected={initialValues.ends_at ?
new Date(
dayjs(initialValues.ends_at).format('YYYY-MM-DD hh:mm'),
) : null
}
onChange={(date) => setInitialValues({...initialValues, 'ends_at': date})}
<Field
type="date"
name="ends_at"
placeholder="EndsAt"
/>
</FormField>
<FormField label='IsCompanyHoliday' labelFor='is_company_holiday'>
<Field
name='is_company_holiday'
@ -468,50 +141,10 @@ const EditHolidaysPage = () => {
component={SwitchField}
></Field>
</FormField>
<FormField label="Notes" hasTextareaHeight>
<Field name="notes" as="textarea" placeholder="Notes" />
</FormField>
<BaseDivider />
<BaseButtons>

View File

@ -28,103 +28,12 @@ import { useRouter } from 'next/router'
import moment from 'moment';
const initialValues = {
holiday_calendar: '',
name: '',
starts_at: '',
ends_at: '',
is_company_holiday: false,
notes: '',
}
@ -139,7 +48,12 @@ const HolidaysNew = () => {
const handleSubmit = async (data) => {
await dispatch(create(data))
const payload = {
...data,
starts_at: data.starts_at ? `${data.starts_at}T00:00:00` : null,
ends_at: data.ends_at ? `${data.ends_at}T23:59:59` : null,
};
await dispatch(create(payload))
await router.push('/holidays/holidays-list')
}
return (
@ -154,53 +68,21 @@ const HolidaysNew = () => {
<CardBox>
<Formik
initialValues={
dateRangeStart && dateRangeEnd ?
{
...initialValues,
starts_at: moment(dateRangeStart).format('YYYY-MM-DDTHH:mm'),
ends_at: moment(dateRangeEnd).format('YYYY-MM-DDTHH:mm'),
starts_at: moment(dateRangeStart).format('YYYY-MM-DD'),
ends_at: moment(dateRangeEnd).format('YYYY-MM-DD'),
} : initialValues
}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label="HolidayCalendar" labelFor="holiday_calendar">
<Field name="holiday_calendar" id="holiday_calendar" component={SelectField} options={[]} itemRef={'holiday_calendars'}></Field>
</FormField>
<FormField
label="Name"
>
@ -210,120 +92,26 @@ const HolidaysNew = () => {
/>
</FormField>
<FormField
label="StartsAt"
>
<Field
type="datetime-local"
type="date"
name="starts_at"
placeholder="StartsAt"
/>
</FormField>
<FormField
label="EndsAt"
>
<Field
type="datetime-local"
type="date"
name="ends_at"
placeholder="EndsAt"
/>
</FormField>
<FormField label='IsCompanyHoliday' labelFor='is_company_holiday'>
<Field
name='is_company_holiday'
@ -332,44 +120,10 @@ const HolidaysNew = () => {
></Field>
</FormField>
<FormField label="Notes" hasTextareaHeight>
<Field name="notes" as="textarea" placeholder="Notes" />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
@ -396,4 +150,4 @@ HolidaysNew.getLayout = function getLayout(page: ReactElement) {
)
}
export default HolidaysNew
export default HolidaysNew

View File

@ -1,166 +1,12 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
import { useEffect } from 'react';
import { useRouter } from 'next/router';
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('background');
const textColor = useAppSelector((state) => state.style.linkColor);
const router = useRouter();
const title = 'ET Vertical PTO'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head>
<title>{getPageTitle('Starter Page')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your ET Vertical PTO app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</div>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
useEffect(() => {
router.replace('/login');
}, [router]);
return null;
}

View File

@ -1,12 +1,10 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import BaseIcon from "../components/BaseIcon";
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
import { mdiEye, mdiEyeOff } from '@mdi/js';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import { Field, Form, Formik } from 'formik';
@ -20,42 +18,44 @@ import { findMe, loginUser, resetAction } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import Link from 'next/link';
import {toast, ToastContainer} from "react-toastify";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
import axios from 'axios';
export default function Login() {
const router = useRouter();
const dispatch = useAppDispatch();
const textColor = useAppSelector((state) => state.style.linkColor);
const iconsColor = useAppSelector((state) => state.style.iconsColor);
const notify = (type, msg) => toast(msg, { type });
const [ illustrationImage, setIllustrationImage ] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('background');
const [showPassword, setShowPassword] = useState(false);
const [backgroundImage, setBackgroundImage] = useState('');
const [showPassword, setShowPassword] = useState(false);
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
(state) => state.auth,
);
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
const initialValues = {
email: 'admin@flatlogic.com',
password: 'ccc3acc2',
remember: true })
remember: true
};
const title = 'ET Vertical PTO'
// Fetch Pexels image/video
useEffect( () => {
async function fetchData() {
const image = await getPexelsImage()
const video = await getPexelsVideo()
setIllustrationImage(image);
setIllustrationVideo(video);
// Fetch background image
useEffect(() => {
const fetchBackground = async () => {
try {
const response = await axios.get('/login_backgrounds/current');
if (response.data && response.data.imageUrl) {
setBackgroundImage(response.data.imageUrl);
}
} catch (error) {
console.error('Failed to fetch login background:', error);
}
fetchData();
};
fetchBackground();
}, []);
// Fetch user data
useEffect(() => {
if (token) {
@ -92,110 +92,23 @@ export default function Login() {
await dispatch(loginUser(rest));
};
const setLogin = (target: HTMLElement) => {
setInitialValues(prev => ({
...prev,
email : target.innerText.trim(),
password: target.dataset.password ?? '',
}));
};
const imageBlock = (image) => (
<div className="hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3"
style={{
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}>
<div className="flex justify-center w-full bg-blue-300/20">
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
by {image?.photographer} on Pexels</a>
</div>
</div>
)
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video.user.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div style={contentPosition === 'background' ? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
<div style={{
backgroundImage: backgroundImage ? `url(${backgroundImage})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))',
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
} : {}}>
}}>
<Head>
<title>{getPageTitle('Login')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
<div className="flex flex-row min-h-screen w-full items-center justify-center">
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
<h2 className="text-4xl font-semibold my-4">{title}</h2>
<div className='flex flex-row text-gray-500 justify-between'>
<div>
<p className='mb-2'>Use{' '}
<code className={`cursor-pointer ${textColor} `}
data-password="ccc3acc2"
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
<code className={`${textColor}`}>ccc3acc2</code>{' / '}
to login as Admin</p>
<p>Use <code
className={`cursor-pointer ${textColor} `}
data-password="da2433513f4f"
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
<code className={`${textColor}`}>da2433513f4f</code>{' / '}
to login as User</p>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={mdiInformation}
/>
</div>
</div>
</CardBox>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBox className='w-full max-w-md'>
<h2 className="text-4xl font-semibold text-center mb-6">{title}</h2>
<Formik
initialValues={initialValues}
enableReinitialize
@ -273,4 +186,4 @@ export default function Login() {
Login.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
};

View File

@ -1,6 +1,7 @@
import {
mdiChartTimelineVariant,
mdiUpload,
mdiDeveloperBoard
} from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
@ -26,6 +27,7 @@ import { SwitchField } from '../components/SwitchField';
import { SelectField } from '../components/SelectField';
import { update, fetch } from '../stores/users/usersSlice';
import { toggleDevInfo } from '../stores/styleSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router';
import {findMe} from "../stores/authSlice";
@ -34,6 +36,8 @@ const EditUsers = () => {
const { currentUser, isFetching, token } = useAppSelector(
(state) => state.auth,
);
const showDevInfo = useAppSelector((state) => state.style.showDevInfo);
const router = useRouter();
const dispatch = useAppDispatch();
const notify = (type, msg) => toast(msg, { type });
@ -168,6 +172,30 @@ const EditUsers = () => {
</Form>
</Formik>
</CardBox>
<SectionTitleLineWithButton
icon={mdiDeveloperBoard}
title='Developer Settings'
className="mt-6"
>
{''}
</SectionTitleLineWithButton>
<CardBox>
<div className="flex items-center justify-between">
<div>
<h3 className="font-bold">Dev View</h3>
<p className="text-gray-500 text-sm">Show database field names in forms</p>
</div>
<FormCheckRadio type="switch" label="">
<input
type="checkbox"
checked={showDevInfo}
onChange={() => dispatch(toggleDevInfo())}
/>
</FormCheckRadio>
</div>
</CardBox>
</SectionMain>
</>
);
@ -177,4 +205,4 @@ EditUsers.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default EditUsers;
export default EditUsers;

View File

@ -0,0 +1,104 @@
import { mdiScaleBalance } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement } from 'react';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { useAppDispatch } from '../../stores/hooks';
import { bulkAdjust } from '../../stores/pto_journal_entries/pto_journal_entriesSlice';
import BaseButton from '../../components/BaseButton';
import FormField from '../../components/FormField';
import { Field, Form, Formik } from 'formik';
import { SelectFieldMany } from '../../components/SelectFieldMany';
const BalanceAdjustmentPage = () => {
const dispatch = useAppDispatch();
const currentYear = new Date().getFullYear();
const handleSubmit = async (values, { setSubmitting, resetForm }) => {
const payload = {
userIds: values.userIds,
entry_type: values.adjustmentType,
leave_bucket: 'regular_pto',
amount_days: values.amountDays,
memo: values.memo,
calendar_year: values.year,
};
await dispatch(bulkAdjust(payload));
setSubmitting(false);
resetForm();
};
return (
<>
<Head>
<title>{getPageTitle('Balance Adjustment')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiScaleBalance} title='Balance Adjustment' main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
initialValues={{
year: currentYear,
adjustmentType: 'credit_manual_adjustment',
amountDays: 0,
memo: '',
userIds: [],
}}
onSubmit={handleSubmit}
>
{({ isSubmitting }) => (
<Form>
<FormField label="Calendar Year">
<Field name="year" as="select" className="w-full h-12 pl-3 pr-10 border-gray-300 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-slate-800">
<option value={currentYear}>{currentYear}</option>
<option value={currentYear + 1}>{currentYear + 1}</option>
<option value={currentYear - 1}>{currentYear - 1}</option>
</Field>
</FormField>
<FormField label="Adjustment Type">
<Field name="adjustmentType" as="select" className="w-full h-12 pl-3 pr-10 border-gray-300 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-slate-800">
<option value="credit_manual_adjustment">Credit (Add Days)</option>
<option value="debit_manual_adjustment">Debit (Remove Days)</option>
</Field>
</FormField>
<FormField label="Amount (Days)">
<Field name="amountDays" type="number" step="0.5" className="w-full h-12 px-3 border-gray-300 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-slate-800" />
</FormField>
<FormField label="Reason / Memo">
<Field name="memo" as="textarea" className="w-full h-24 px-3 py-2 border-gray-300 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 dark:bg-slate-800" />
</FormField>
<div className="mb-4">
<label className="block font-bold mb-2">Select Users (Single or Bulk)</label>
<Field
name="userIds"
component={SelectFieldMany}
itemRef="users"
/>
</div>
<BaseButton type="submit" label="Submit Adjustment" color="info" disabled={isSubmitting} />
</Form>
)}
</Formik>
</CardBox>
</SectionMain>
</>
);
};
BalanceAdjustmentPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default BalanceAdjustmentPage;

View File

@ -2,7 +2,7 @@ import React, { ReactElement, useState, useEffect } from 'react';
import Head from 'next/head';
import { Formik, Form, Field } from 'formik';
import axios from 'axios';
import { mdiCog, mdiCheck } from '@mdi/js';
import { mdiCog, mdiCheck, mdiCalendar, mdiImageArea } from '@mdi/js';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
@ -14,8 +14,10 @@ import { getPageTitle } from '../../config';
import { SelectFieldMany } from '../../components/SelectFieldMany';
import { SwitchField } from '../../components/SwitchField';
import moment from 'moment';
import { useRouter } from 'next/router';
const SettingsPage = () => {
const router = useRouter();
const [initialValues, setInitialValues] = useState({
lockoutEnabled: false,
lockoutUntil: '',
@ -43,32 +45,6 @@ const SettingsPage = () => {
const handleSubmit = async (values) => {
try {
// Transform allowedUserIds back to array of strings if needed
// But SelectFieldMany returns array of IDs usually? No, let's check.
// SelectFieldMany: form.setFieldValue(field.name, data.map(el => (el?.value || null)));
// So it sets array of IDs.
// Wait, initialValues allowedUserIds expects array of IDs? No, the component expects objects if using `options` or `value` logic internally?
// `field.value` in SelectFieldMany is used.
// If `field.value` is `['id1']`, `useEffect` inside `SelectFieldMany` runs:
// if (field.value?.[0] && typeof field.value[0] !== 'string') -> map to IDs.
// So if I pass objects, it maps to IDs. If I pass IDs, it's fine.
// But `options` are loaded async.
// I'll stick to passing array of IDs in initialValues.
// `SelectFieldMany` implementation is a bit weird. It sets value from `options` prop change or `field.value` change.
// If `options` is empty initially, `value` is empty.
// But `loadOptions` fetches options.
// Since `options` prop is `[]` in my usage (`options={[]}`), it relies on `loadOptions`.
// `AsyncPaginate` handles initial value label resolution if `defaultOptions` is true? No.
// Usually async select needs the full object to show the label for existing value.
// I might need to fetch the full user objects for allowedUserIds to display them correctly initially.
// I'll skip fetching full objects for now as it complicates things. It might just show IDs or empty labels until loaded?
// Actually, `SelectFieldMany` in this codebase seems designed to receive the FULL options list in `options` prop if not using `loadOptions` exclusively for search?
// No, `loadOptions={callApi}` is passed.
// The issue is displaying the initial selection.
// Without fetching the user objects (labels), the select won't show names.
// I'll leave it as is for now. The user didn't ask for perfection on the UI label loading, just the feature.
// I'll pass IDs.
const payload = {
...values,
allowedUserIds: values.allowedUserIds, // Already array of IDs from SelectFieldMany
@ -149,6 +125,28 @@ const SettingsPage = () => {
)}
</Formik>
</CardBox>
<SectionTitleLineWithButton icon={mdiCog} title="Data Management" />
<CardBox>
<div className="flex flex-col space-y-4">
<p>Manage holiday calendars and other system data.</p>
<BaseButtons>
<BaseButton
color="info"
label="Manage Holiday Calendars"
icon={mdiCalendar}
onClick={() => router.push('/holiday_calendars/holiday_calendars-list')}
/>
<BaseButton
color="info"
label="Manage Login Backgrounds"
icon={mdiImageArea}
onClick={() => router.push('/settings/login-backgrounds')}
/>
</BaseButtons>
</div>
</CardBox>
</SectionMain>
</>
);
@ -158,4 +156,4 @@ SettingsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default SettingsPage;
export default SettingsPage;

View File

@ -0,0 +1,149 @@
import React, { ReactElement, useState, useEffect } from 'react';
import Head from 'next/head';
import { mdiImageArea, mdiCloudUpload, mdiDelete } from '@mdi/js';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import BaseButton from '../../components/BaseButton';
import BaseButtons from '../../components/BaseButtons';
import { getPageTitle } from '../../config';
import axios from 'axios';
import FileUploader from '../../components/Uploaders/UploadService';
const months = [
{ value: 0, label: 'Default' },
{ value: 1, label: 'January' },
{ value: 2, label: 'February' },
{ value: 3, label: 'March' },
{ value: 4, label: 'April' },
{ value: 5, label: 'May' },
{ value: 6, label: 'June' },
{ value: 7, label: 'July' },
{ value: 8, label: 'August' },
{ value: 9, label: 'September' },
{ value: 10, label: 'October' },
{ value: 11, label: 'November' },
{ value: 12, label: 'December' },
];
const LoginBackgroundsPage = () => {
const [backgrounds, setBackgrounds] = useState([]);
const [loading, setLoading] = useState(false);
const fetchBackgrounds = async () => {
try {
const response = await axios.get('/login_backgrounds');
setBackgrounds(response.data.rows);
} catch (error) {
console.error('Failed to fetch backgrounds:', error);
}
};
useEffect(() => {
fetchBackgrounds();
}, []);
const handleUpload = async (month, file) => {
if (!file) return;
setLoading(true);
try {
// 1. Upload file
const uploadedFile = await FileUploader.upload('login_backgrounds', file, { image: true });
// 2. Save record
const existing = backgrounds.find(b => b.month === month);
if (existing) {
await axios.put(`/login_backgrounds/${existing.id}`, { month, image: [uploadedFile] });
} else {
await axios.post('/login_backgrounds', { month, image: [uploadedFile] });
}
await fetchBackgrounds();
} catch (error) {
console.error('Failed to upload:', error);
alert('Upload failed');
} finally {
setLoading(false);
}
};
const handleRemove = async (id) => {
if (!confirm('Are you sure you want to remove this background?')) return;
setLoading(true);
try {
await axios.delete(`/login_backgrounds/${id}`);
await fetchBackgrounds();
} catch (error) {
console.error('Failed to remove:', error);
} finally {
setLoading(false);
}
};
return (
<>
<Head>
<title>{getPageTitle('Login Backgrounds')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiImageArea} title="Login Backgrounds" main />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{months.map((month) => {
const bg = backgrounds.find((b) => b.month === month.value);
const imageUrl = bg?.image?.[0]?.publicUrl;
return (
<CardBox key={month.value} className="flex flex-col items-center justify-between h-full">
<h3 className="text-lg font-bold mb-4">{month.label}</h3>
<div className="w-full h-48 bg-gray-100 dark:bg-gray-800 mb-4 flex items-center justify-center overflow-hidden rounded relative">
{imageUrl ? (
<img src={imageUrl} alt={month.label} className="w-full h-full object-cover" />
) : (
<span className="text-gray-400">No Image</span>
)}
</div>
<BaseButtons>
<div className="relative overflow-hidden inline-block">
<BaseButton
color="info"
label={imageUrl ? "Change" : "Upload"}
icon={mdiCloudUpload}
disabled={loading}
/>
<input
type="file"
className="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
accept="image/*"
onChange={(e) => handleUpload(month.value, e.target.files[0])}
disabled={loading}
/>
</div>
{bg && (
<BaseButton
color="danger"
icon={mdiDelete}
onClick={() => handleRemove(bg.id)}
disabled={loading}
/>
)}
</BaseButtons>
</CardBox>
);
})}
</div>
</SectionMain>
</>
);
};
LoginBackgroundsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default LoginBackgroundsPage;

View File

@ -122,6 +122,22 @@ export const update = createAsyncThunk('pto_journal_entries/updatePto_journal_en
}
})
export const bulkAdjust = createAsyncThunk('pto_journal_entries/bulkAdjust', async (data: any, { rejectWithValue }) => {
try {
const result = await axios.post(
'pto_journal_entries/bulk-adjust',
{ data }
)
return result.data
} catch (error) {
if (!error.response) {
throw error;
}
return rejectWithValue(error.response.data);
}
})
export const pto_journal_entriesSlice = createSlice({
name: 'pto_journal_entries',
@ -221,6 +237,19 @@ export const pto_journal_entriesSlice = createSlice({
rejectNotify(state, action);
})
builder.addCase(bulkAdjust.pending, (state) => {
state.loading = true
resetNotify(state);
})
builder.addCase(bulkAdjust.fulfilled, (state) => {
state.loading = false
fulfilledNotify(state, 'Balance adjustment successful');
})
builder.addCase(bulkAdjust.rejected, (state, action) => {
state.loading = false
rejectNotify(state, action);
})
},
})
@ -228,4 +257,4 @@ export const pto_journal_entriesSlice = createSlice({
// Action creators are generated for each case reducer function
export const { setRefetch } = pto_journal_entriesSlice.actions
export default pto_journal_entriesSlice.reducer
export default pto_journal_entriesSlice.reducer

View File

@ -28,6 +28,7 @@ interface StyleState {
shadow: string;
websiteSectionStyle: string;
textSecondary: string;
showDevInfo: boolean;
}
@ -56,6 +57,7 @@ const initialState: StyleState = {
shadow: styles.white.shadow,
websiteSectionStyle: styles.white.websiteSectionStyle,
textSecondary: styles.white.textSecondary,
showDevInfo: false,
}
@ -94,10 +96,14 @@ export const styleSlice = createSlice({
state[`${key}Style`] = style[key]
}
},
toggleDevInfo: (state) => {
state.showDevInfo = !state.showDevInfo;
}
},
})
// Action creators are generated for each case reducer function
export const { setDarkMode, setStyle } = styleSlice.actions
export const { setDarkMode, setStyle, toggleDevInfo } = styleSlice.actions
export default styleSlice.reducer
export default styleSlice.reducer