Coastal Timesheet v2 — full feature buildout

Features:
- Copy Previous Week (entries duplication)
- Bulk Approve/Reject (admin workflow)
- Overtime tracking (employee + admin views)
- DayCard component with auto-save
- PDF generation, email notifications
- React + Tailwind frontend, Prisma + PostgreSQL backend
- Docker deployment (3 containers)
This commit is contained in:
BizzleBot
2026-02-16 10:01:39 +00:00
commit f718a76153
65 changed files with 6756 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
import axios from 'axios';
const client = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
timeout: 15000,
});
/* ─── Token helpers ─── */
function getAccessToken() {
return localStorage.getItem('accessToken');
}
function getRefreshToken() {
return localStorage.getItem('refreshToken');
}
function setTokens(accessToken, refreshToken) {
localStorage.setItem('accessToken', accessToken);
if (refreshToken) {
localStorage.setItem('refreshToken', refreshToken);
}
}
function clearTokens() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
}
/* ─── Request interceptor: attach access token ─── */
client.interceptors.request.use(
(config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
/* ─── Response interceptor: auto-refresh on 401 ─── */
let isRefreshing = false;
let failedQueue = [];
function processQueue(error, token) {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
}
client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
/* If 401 with TOKEN_EXPIRED and we haven't retried yet */
if (
error.response?.status === 401 &&
error.response?.data?.code === 'TOKEN_EXPIRED' &&
!originalRequest._retry
) {
if (isRefreshing) {
/* Queue this request until the refresh completes */
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return client(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token');
}
/* Call refresh endpoint directly (bypass interceptor) */
const { data } = await axios.post('/api/auth/refresh', {
refreshToken,
});
setTokens(data.accessToken, data.refreshToken);
processQueue(null, data.accessToken);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return client(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
clearTokens();
/* Redirect to login */
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
/* If 401 on non-refresh, clear and redirect */
if (
error.response?.status === 401 &&
!originalRequest.url?.includes('/auth/refresh')
) {
clearTokens();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export { client as default, setTokens, clearTokens, getAccessToken, getRefreshToken };