Add config API and settings UI
GET/PUT /api/config for pay dates and amounts. Settings page fetches and saves configuration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,238 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const fieldStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '4px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle = {
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
padding: '8px 10px',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
width: '180px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionStyle = {
|
||||||
|
marginBottom: '32px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionTitleStyle = {
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: '16px',
|
||||||
|
borderBottom: '2px solid #e5e7eb',
|
||||||
|
paddingBottom: '8px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitStyle = {
|
||||||
|
padding: '10px 24px',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: '600',
|
||||||
|
background: '#2563eb',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const successStyle = {
|
||||||
|
color: '#16a34a',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: '12px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FORM = {
|
||||||
|
paycheck1_day: '',
|
||||||
|
paycheck2_day: '',
|
||||||
|
paycheck1_gross: '',
|
||||||
|
paycheck1_net: '',
|
||||||
|
paycheck2_gross: '',
|
||||||
|
paycheck2_net: '',
|
||||||
|
};
|
||||||
|
|
||||||
function Settings() {
|
function Settings() {
|
||||||
return <div><h1>Settings</h1><p>Placeholder — coming soon.</p></div>;
|
const [form, setForm] = useState(DEFAULT_FORM);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/config')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
setForm({
|
||||||
|
paycheck1_day: data.paycheck1_day ?? '',
|
||||||
|
paycheck2_day: data.paycheck2_day ?? '',
|
||||||
|
paycheck1_gross: data.paycheck1_gross ?? '',
|
||||||
|
paycheck1_net: data.paycheck1_net ?? '',
|
||||||
|
paycheck2_gross: data.paycheck2_gross ?? '',
|
||||||
|
paycheck2_net: data.paycheck2_net ?? '',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => setError('Failed to load settings.'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleChange(e) {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setSaved(false);
|
||||||
|
setForm((prev) => ({ ...prev, [name]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSaved(false);
|
||||||
|
|
||||||
|
const payload = {};
|
||||||
|
for (const [key, val] of Object.entries(form)) {
|
||||||
|
if (val !== '' && val !== null) {
|
||||||
|
payload[key] = Number(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error('Save failed');
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setForm({
|
||||||
|
paycheck1_day: data.paycheck1_day ?? '',
|
||||||
|
paycheck2_day: data.paycheck2_day ?? '',
|
||||||
|
paycheck1_gross: data.paycheck1_gross ?? '',
|
||||||
|
paycheck1_net: data.paycheck1_net ?? '',
|
||||||
|
paycheck2_gross: data.paycheck2_gross ?? '',
|
||||||
|
paycheck2_net: data.paycheck2_net ?? '',
|
||||||
|
});
|
||||||
|
setSaved(true);
|
||||||
|
})
|
||||||
|
.catch(() => setError('Failed to save settings.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '480px', margin: '32px auto', padding: '0 16px' }}>
|
||||||
|
<h1 style={{ fontSize: '24px', fontWeight: '800', marginBottom: '24px' }}>Settings</h1>
|
||||||
|
|
||||||
|
{error && <p style={{ color: '#dc2626', marginBottom: '16px' }}>{error}</p>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<div style={sectionTitleStyle}>Pay Schedule</div>
|
||||||
|
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
<label style={labelStyle} htmlFor="paycheck1_day">Paycheck 1 Day</label>
|
||||||
|
<input
|
||||||
|
id="paycheck1_day"
|
||||||
|
name="paycheck1_day"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="28"
|
||||||
|
value={form.paycheck1_day}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
<label style={labelStyle} htmlFor="paycheck2_day">Paycheck 2 Day</label>
|
||||||
|
<input
|
||||||
|
id="paycheck2_day"
|
||||||
|
name="paycheck2_day"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="28"
|
||||||
|
value={form.paycheck2_day}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<div style={sectionTitleStyle}>Paycheck Amounts</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<div style={{ fontWeight: '700', marginBottom: '10px' }}>Paycheck 1</div>
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
<label style={labelStyle} htmlFor="paycheck1_gross">Gross</label>
|
||||||
|
<input
|
||||||
|
id="paycheck1_gross"
|
||||||
|
name="paycheck1_gross"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={form.paycheck1_gross}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
<label style={labelStyle} htmlFor="paycheck1_net">Net</label>
|
||||||
|
<input
|
||||||
|
id="paycheck1_net"
|
||||||
|
name="paycheck1_net"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={form.paycheck1_net}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: '700', marginBottom: '10px' }}>Paycheck 2</div>
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
<label style={labelStyle} htmlFor="paycheck2_gross">Gross</label>
|
||||||
|
<input
|
||||||
|
id="paycheck2_gross"
|
||||||
|
name="paycheck2_gross"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={form.paycheck2_gross}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
<label style={labelStyle} htmlFor="paycheck2_net">Net</label>
|
||||||
|
<input
|
||||||
|
id="paycheck2_net"
|
||||||
|
name="paycheck2_net"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={form.paycheck2_net}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" style={submitStyle}>Save Settings</button>
|
||||||
|
|
||||||
|
{saved && <p style={successStyle}>Settings saved</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Settings;
|
export default Settings;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const express = require('express');
|
|||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const healthRouter = require('./routes/health');
|
const healthRouter = require('./routes/health');
|
||||||
|
const configRouter = require('./routes/config');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -13,6 +14,7 @@ app.use(express.json());
|
|||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
app.use('/api', healthRouter);
|
app.use('/api', healthRouter);
|
||||||
|
app.use('/api', configRouter);
|
||||||
|
|
||||||
// Serve static client files in production
|
// Serve static client files in production
|
||||||
const clientDist = path.join(__dirname, '../../client/dist');
|
const clientDist = path.join(__dirname, '../../client/dist');
|
||||||
|
|||||||
82
server/src/routes/config.js
Normal file
82
server/src/routes/config.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { pool } = require('../db');
|
||||||
|
|
||||||
|
const CONFIG_KEYS = [
|
||||||
|
'paycheck1_day',
|
||||||
|
'paycheck2_day',
|
||||||
|
'paycheck1_gross',
|
||||||
|
'paycheck1_net',
|
||||||
|
'paycheck2_gross',
|
||||||
|
'paycheck2_net',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
paycheck1_day: 1,
|
||||||
|
paycheck2_day: 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getAllConfig() {
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT key, value FROM config WHERE key = ANY($1)',
|
||||||
|
[CONFIG_KEYS]
|
||||||
|
);
|
||||||
|
|
||||||
|
const map = {};
|
||||||
|
for (const row of result.rows) {
|
||||||
|
map[row.key] = row.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {};
|
||||||
|
for (const key of CONFIG_KEYS) {
|
||||||
|
const raw = map[key] !== undefined ? map[key] : (DEFAULTS[key] !== undefined ? String(DEFAULTS[key]) : null);
|
||||||
|
config[key] = raw !== null ? Number(raw) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/config', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = await getAllConfig();
|
||||||
|
res.json(config);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/config error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch config' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/config', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const updates = req.body;
|
||||||
|
const validKeys = Object.keys(updates).filter((k) => CONFIG_KEYS.includes(k));
|
||||||
|
|
||||||
|
if (validKeys.length > 0) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
for (const key of validKeys) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO config (key, value) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||||
|
[key, String(updates[key])]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getAllConfig();
|
||||||
|
res.json(config);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PUT /api/config error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update config' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user