updating the layout based on the endpoints
This commit is contained in:
+4
-6
@@ -1,17 +1,15 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { defaultBackendPreference } from "@api/backendConfig";
|
|
||||||
import { usePathname } from "@hooks/usePathname";
|
import { usePathname } from "@hooks/usePathname";
|
||||||
import { AppLayout } from "@layouts/AppLayout";
|
import { AppLayout } from "@layouts/AppLayout";
|
||||||
import { CompaniesPage } from "@pages/CompaniesPage";
|
import { CompaniesPage } from "@pages/CompaniesPage";
|
||||||
import { HomePage } from "@pages/HomePage";
|
import { HomePage } from "@pages/HomePage";
|
||||||
import { SchedulePage } from "@pages/SchedulePage";
|
import { SchedulePage } from "@pages/SchedulePage";
|
||||||
import { UsersPage } from "@pages/UsersPage";
|
import { UsersPage } from "@pages/UsersPage";
|
||||||
import type { BackendPreference } from "@/types/backend";
|
import { useBackendPreferenceStore } from "./store/backendPreferenceStore";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [pathname, navigate] = usePathname();
|
const [pathname, navigate] = usePathname();
|
||||||
const [backendPreference, setBackendPreference] =
|
const backendPreference = useBackendPreferenceStore((state) => state.backendPreference);
|
||||||
useState<BackendPreference>(defaultBackendPreference);
|
const setBackendPreference = useBackendPreferenceStore((state) => state.setBackendPreference);
|
||||||
|
|
||||||
const renderPage = () => {
|
const renderPage = () => {
|
||||||
switch (pathname) {
|
switch (pathname) {
|
||||||
@@ -22,7 +20,7 @@ function App() {
|
|||||||
case "/companies":
|
case "/companies":
|
||||||
return <CompaniesPage backendPreference={backendPreference} />;
|
return <CompaniesPage backendPreference={backendPreference} />;
|
||||||
default:
|
default:
|
||||||
return <HomePage backendPreference={backendPreference} />;
|
return <HomePage backendPreference={backendPreference} onNavigate={navigate} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+56
-4
@@ -5,6 +5,58 @@ type ApiRequestOptions = RequestInit & {
|
|||||||
backendPreference: BackendPreference;
|
backendPreference: BackendPreference;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatErrorValue(value: unknown): string | null {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(formatErrorValue).filter(Boolean).join(", ") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.entries(value)
|
||||||
|
.map(([key, entryValue]) => {
|
||||||
|
const formattedValue = formatErrorValue(entryValue);
|
||||||
|
|
||||||
|
return formattedValue ? `${key}: ${formattedValue}` : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(responseText: string, status: number): string {
|
||||||
|
if (!responseText) {
|
||||||
|
return `API request failed with status ${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedResponse = JSON.parse(responseText) as unknown;
|
||||||
|
|
||||||
|
if (parsedResponse && typeof parsedResponse === "object" && !Array.isArray(parsedResponse)) {
|
||||||
|
const errorResponse = parsedResponse as Record<string, unknown>;
|
||||||
|
const message =
|
||||||
|
formatErrorValue(errorResponse.message) ??
|
||||||
|
formatErrorValue(errorResponse.error) ??
|
||||||
|
formatErrorValue(errorResponse.detail) ??
|
||||||
|
formatErrorValue(errorResponse);
|
||||||
|
|
||||||
|
return message ?? `API request failed with status ${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatErrorValue(parsedResponse) ?? `API request failed with status ${status}`;
|
||||||
|
} catch {
|
||||||
|
return responseText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildApiUrl(backendPreference: BackendPreference, endpoint: string): string {
|
export function buildApiUrl(backendPreference: BackendPreference, endpoint: string): string {
|
||||||
const backend = getBackendOption(backendPreference);
|
const backend = getBackendOption(backendPreference);
|
||||||
return `${backend.baseUrl}${endpoint}`;
|
return `${backend.baseUrl}${endpoint}`;
|
||||||
@@ -22,12 +74,12 @@ export async function apiRequest<TResponse>(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API request failed with status ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseText = await response.text();
|
const responseText = await response.text();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(getErrorMessage(responseText, response.status));
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 204 || !responseText) {
|
if (response.status === 204 || !responseText) {
|
||||||
return undefined as TResponse;
|
return undefined as TResponse;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
padding: 12px 18px;
|
padding: 12px 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
height: 40px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08);
|
box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08);
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
h3,
|
h3,
|
||||||
p {
|
p {
|
||||||
|
|||||||
@@ -5,10 +5,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
text-align: left;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08);
|
box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 24px 58px rgba(17, 24, 39, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 3px solid #93c5fd;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import styles from "./HomePage.module.scss";
|
|||||||
|
|
||||||
type HomePageProps = {
|
type HomePageProps = {
|
||||||
backendPreference: BackendPreference;
|
backendPreference: BackendPreference;
|
||||||
|
onNavigate: (path: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function HomePage({ backendPreference }: HomePageProps) {
|
export function HomePage({ backendPreference, onNavigate }: HomePageProps) {
|
||||||
const [companies, setCompanies] = useState<Company[]>([]);
|
const [companies, setCompanies] = useState<Company[]>([]);
|
||||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||||
const [users, setUsers] = useState<Employee[]>([]);
|
const [users, setUsers] = useState<Employee[]>([]);
|
||||||
@@ -77,18 +78,18 @@ export function HomePage({ backendPreference }: HomePageProps) {
|
|||||||
{error ? <p className={styles.error}>{error}</p> : null}
|
{error ? <p className={styles.error}>{error}</p> : null}
|
||||||
{isLoading ? <p className={styles.muted}>Loading dashboard...</p> : null}
|
{isLoading ? <p className={styles.muted}>Loading dashboard...</p> : null}
|
||||||
<section className={styles.statsGrid} aria-label="Dashboard overview">
|
<section className={styles.statsGrid} aria-label="Dashboard overview">
|
||||||
<article className={styles.card}>
|
<button className={styles.card} onClick={() => onNavigate("/users")} type="button">
|
||||||
<span>Total users</span>
|
<span>Total users</span>
|
||||||
<strong>{users.length}</strong>
|
<strong>{users.length}</strong>
|
||||||
</article>
|
</button>
|
||||||
<article className={styles.card}>
|
<button className={styles.card} onClick={() => onNavigate("/schedule")} type="button">
|
||||||
<span>Working today</span>
|
<span>Working today</span>
|
||||||
<strong>{workingToday}</strong>
|
<strong>{workingToday}</strong>
|
||||||
</article>
|
</button>
|
||||||
<article className={styles.card}>
|
<button className={styles.card} onClick={() => onNavigate("/companies")} type="button">
|
||||||
<span>Companies</span>
|
<span>Companies</span>
|
||||||
<strong>{companies.length}</strong>
|
<strong>{companies.length}</strong>
|
||||||
</article>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,11 @@
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
color: #172033;
|
color: #172033;
|
||||||
box-shadow: inset 0 0 0 1px #d9e1ec;
|
box-shadow: inset 0 0 0 1px #d9e1ec;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionGroup {
|
.actionGroup {
|
||||||
@@ -50,6 +55,34 @@
|
|||||||
box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08);
|
box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.usersToolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.companyFilter {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: min(320px, 100%);
|
||||||
|
color: #687487;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
color: #1f2937;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 14px 35px rgba(17, 24, 39, 0.07);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tableScroll {
|
.tableScroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -58,6 +91,20 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
min-width: 760px;
|
min-width: 760px;
|
||||||
|
table-layout: fixed;
|
||||||
|
|
||||||
|
thead,
|
||||||
|
tbody tr {
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody {
|
||||||
|
display: block;
|
||||||
|
max-height: 520px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
@@ -83,6 +130,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
color: #687487;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.selectedRow {
|
.selectedRow {
|
||||||
background: #eef4ff;
|
background: #eef4ff;
|
||||||
}
|
}
|
||||||
|
|||||||
+176
-18
@@ -18,20 +18,51 @@ type UsersPageProps = {
|
|||||||
type UserStatus = "off-duty" | "working" | "worked";
|
type UserStatus = "off-duty" | "working" | "worked";
|
||||||
|
|
||||||
const workTimeOptions: WorkTime[] = ["morning", "afternoon", "evening"];
|
const workTimeOptions: WorkTime[] = ["morning", "afternoon", "evening"];
|
||||||
|
const statusOptions: UserStatus[] = ["off-duty", "working", "worked"];
|
||||||
|
const usersPerPage = 10;
|
||||||
|
|
||||||
function getUserStatus(schedules: Schedule[]): UserStatus {
|
function isSameLocalDate(firstDate: Date, secondDate: Date): boolean {
|
||||||
const hasCheckIn = schedules.some((schedule) => schedule.enter_time);
|
return (
|
||||||
const hasActiveShift = schedules.some((schedule) => schedule.enter_time && !schedule.out_time);
|
firstDate.getFullYear() === secondDate.getFullYear() &&
|
||||||
|
firstDate.getMonth() === secondDate.getMonth() &&
|
||||||
|
firstDate.getDate() === secondDate.getDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (hasActiveShift) {
|
function getTodaySchedules(schedules: Schedule[], today: Date): Schedule[] {
|
||||||
return "working";
|
return schedules.filter((schedule) => {
|
||||||
|
if (!schedule.enter_time) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterDate = new Date(schedule.enter_time);
|
||||||
|
|
||||||
|
return !Number.isNaN(enterDate.getTime()) && isSameLocalDate(enterDate, today);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestSchedule(schedules: Schedule[]): Schedule | undefined {
|
||||||
|
return [...schedules].sort((firstSchedule, secondSchedule) => {
|
||||||
|
const firstTime = firstSchedule.enter_time ? new Date(firstSchedule.enter_time).getTime() : 0;
|
||||||
|
const secondTime = secondSchedule.enter_time ? new Date(secondSchedule.enter_time).getTime() : 0;
|
||||||
|
|
||||||
|
return firstTime - secondTime;
|
||||||
|
}).at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserStatus(schedules: Schedule[], today: Date): UserStatus {
|
||||||
|
const todaySchedules = getTodaySchedules(schedules, today);
|
||||||
|
const latestSchedule = getLatestSchedule(todaySchedules);
|
||||||
|
|
||||||
|
if (!latestSchedule?.enter_time) {
|
||||||
|
return "off-duty";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasCheckIn) {
|
if (latestSchedule.out_time) {
|
||||||
return "worked";
|
return "worked";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "off-duty";
|
return "working";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusLabel(status: UserStatus): string {
|
function getStatusLabel(status: UserStatus): string {
|
||||||
@@ -54,6 +85,30 @@ function formatDateTime(value: string | null): string {
|
|||||||
return new Date(value).toLocaleString();
|
return new Date(value).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNameScheduleKey(name: string, lastName: string): string {
|
||||||
|
return `name:${name.trim().toLowerCase()}:${lastName.trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmployeeScheduleKeys(user: Employee): string[] {
|
||||||
|
return [`id:${user.id}`, getNameScheduleKey(user.name, user.last_name)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScheduleUserKey(schedule: Schedule): string | null {
|
||||||
|
if (typeof schedule.user_id === "number") {
|
||||||
|
return `id:${schedule.user_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schedule.user) {
|
||||||
|
return getNameScheduleKey(schedule.user.name, schedule.user.last_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSchedulesForUser(user: Employee, schedulesByUser: Record<string, Schedule[]>): Schedule[] {
|
||||||
|
return getEmployeeScheduleKeys(user).flatMap((key) => schedulesByUser[key] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
export function UsersPage({ backendPreference }: UsersPageProps) {
|
export function UsersPage({ backendPreference }: UsersPageProps) {
|
||||||
const [companies, setCompanies] = useState<Company[]>([]);
|
const [companies, setCompanies] = useState<Company[]>([]);
|
||||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||||
@@ -71,20 +126,42 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
|
|||||||
const [savingActionUserId, setSavingActionUserId] = useState<number | null>(null);
|
const [savingActionUserId, setSavingActionUserId] = useState<number | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [selectedCompanyId, setSelectedCompanyId] = useState("");
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<UserStatus | "">("");
|
||||||
|
|
||||||
const selectedUser = users.find((user) => user.id === selectedUserId) ?? null;
|
const selectedUser = users.find((user) => user.id === selectedUserId) ?? null;
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
const schedulesByUser = useMemo(() => {
|
const schedulesByUser = useMemo(() => {
|
||||||
return schedules.reduce<Record<number, Schedule[]>>((accumulator, schedule) => {
|
return schedules.reduce<Record<string, Schedule[]>>((accumulator, schedule) => {
|
||||||
if (typeof schedule.user_id !== "number") {
|
const userKey = getScheduleUserKey(schedule);
|
||||||
|
|
||||||
|
if (!userKey) {
|
||||||
return accumulator;
|
return accumulator;
|
||||||
}
|
}
|
||||||
|
|
||||||
accumulator[schedule.user_id] = [...(accumulator[schedule.user_id] ?? []), schedule];
|
accumulator[userKey] = [...(accumulator[userKey] ?? []), schedule];
|
||||||
return accumulator;
|
return accumulator;
|
||||||
}, {});
|
}, {});
|
||||||
}, [schedules]);
|
}, [schedules]);
|
||||||
|
|
||||||
|
const filteredUsers = users.filter((user) => {
|
||||||
|
if (selectedCompanyId && user.company_id !== Number(selectedCompanyId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedStatus && getUserStatus(getSchedulesForUser(user, schedulesByUser), today) !== selectedStatus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredUsers.length / usersPerPage));
|
||||||
|
const pageStartIndex = (currentPage - 1) * usersPerPage;
|
||||||
|
const paginatedUsers = filteredUsers.slice(pageStartIndex, pageStartIndex + usersPerPage);
|
||||||
|
const pageEndIndex = pageStartIndex + paginatedUsers.length;
|
||||||
|
|
||||||
const loadUsersPage = useCallback(async () => {
|
const loadUsersPage = useCallback(async () => {
|
||||||
setError("");
|
setError("");
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -110,6 +187,16 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
|
|||||||
void loadUsersPage();
|
void loadUsersPage();
|
||||||
}, [loadUsersPage]);
|
}, [loadUsersPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage > totalPages) {
|
||||||
|
setCurrentPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [selectedCompanyId, selectedStatus]);
|
||||||
|
|
||||||
function resetUserForm() {
|
function resetUserForm() {
|
||||||
setCompanyId("");
|
setCompanyId("");
|
||||||
setFirstName("");
|
setFirstName("");
|
||||||
@@ -173,6 +260,20 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
|
|||||||
return days[new Date(value).getDay()];
|
return days[new Date(value).getDay()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getShiftFromDateTime(value: string): WorkTime {
|
||||||
|
const hour = new Date(value).getHours();
|
||||||
|
|
||||||
|
if (hour < 12) {
|
||||||
|
return "morning";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hour < 19) {
|
||||||
|
return "evening";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "afternoon";
|
||||||
|
}
|
||||||
|
|
||||||
function openCheckInModal(user: Employee) {
|
function openCheckInModal(user: Employee) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000)
|
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000)
|
||||||
@@ -196,6 +297,7 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
|
|||||||
await clockIn(backendPreference, checkingInUser.id, {
|
await clockIn(backendPreference, checkingInUser.id, {
|
||||||
day_of_week: getDayOfWeekFromDateTime(checkInDateTime),
|
day_of_week: getDayOfWeekFromDateTime(checkInDateTime),
|
||||||
enter_time: new Date(checkInDateTime).toISOString(),
|
enter_time: new Date(checkInDateTime).toISOString(),
|
||||||
|
shift: getShiftFromDateTime(checkInDateTime),
|
||||||
});
|
});
|
||||||
setCheckingInUser(null);
|
setCheckingInUser(null);
|
||||||
await loadUsersPage();
|
await loadUsersPage();
|
||||||
@@ -249,6 +351,34 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
|
|||||||
/>
|
/>
|
||||||
{error ? <p className={styles.error}>{error}</p> : null}
|
{error ? <p className={styles.error}>{error}</p> : null}
|
||||||
{isLoading ? <p className={styles.muted}>Loading users...</p> : null}
|
{isLoading ? <p className={styles.muted}>Loading users...</p> : null}
|
||||||
|
<div className={styles.usersToolbar}>
|
||||||
|
<label className={styles.companyFilter}>
|
||||||
|
<select
|
||||||
|
value={selectedCompanyId}
|
||||||
|
onChange={(event) => setSelectedCompanyId(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All companies</option>
|
||||||
|
{companies.map((company) => (
|
||||||
|
<option key={company.id} value={company.id}>
|
||||||
|
{company.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.companyFilter}>
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(event) => setSelectedStatus(event.target.value as UserStatus | "")}
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
{statusOptions.map((status) => (
|
||||||
|
<option key={status} value={status}>
|
||||||
|
{getStatusLabel(status)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<section className={styles.tableCard} aria-label="Users table">
|
<section className={styles.tableCard} aria-label="Users table">
|
||||||
<div className={styles.tableScroll}>
|
<div className={styles.tableScroll}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
@@ -265,13 +395,14 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((user) => {
|
{paginatedUsers.map((user) => {
|
||||||
const userSchedules = schedulesByUser[user.id] ?? [];
|
const userSchedules = getSchedulesForUser(user, schedulesByUser);
|
||||||
const status = getUserStatus(userSchedules);
|
const todaySchedules = getTodaySchedules(userSchedules, today);
|
||||||
const activeSchedule = userSchedules.find(
|
const status = getUserStatus(userSchedules, today);
|
||||||
(schedule) => schedule.enter_time && !schedule.out_time,
|
const latestSchedule = getLatestSchedule(todaySchedules);
|
||||||
);
|
const activeSchedule = latestSchedule?.enter_time && !latestSchedule.out_time
|
||||||
const latestSchedule = userSchedules.at(-1);
|
? latestSchedule
|
||||||
|
: undefined;
|
||||||
const company = companies.find((item) => item.id === user.company_id);
|
const company = companies.find((item) => item.id === user.company_id);
|
||||||
const daysWorked = new Set(
|
const daysWorked = new Set(
|
||||||
userSchedules
|
userSchedules
|
||||||
@@ -306,7 +437,7 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
|
|||||||
<div className={styles.actionGroup}>
|
<div className={styles.actionGroup}>
|
||||||
<button
|
<button
|
||||||
className={styles.actionButton}
|
className={styles.actionButton}
|
||||||
disabled={Boolean(activeSchedule) || savingActionUserId === user.id}
|
disabled={status !== "off-duty" || savingActionUserId === user.id}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
openCheckInModal(user);
|
openCheckInModal(user);
|
||||||
@@ -337,6 +468,33 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<span>
|
||||||
|
Showing {filteredUsers.length === 0 ? 0 : pageStartIndex + 1}-{pageEndIndex} of{" "}
|
||||||
|
{filteredUsers.length} users
|
||||||
|
</span>
|
||||||
|
<div className={styles.paginationActions}>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isAddUserOpen ? (
|
{isAddUserOpen ? (
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import { defaultBackendPreference } from "@api/backendConfig";
|
||||||
|
import type { BackendPreference } from "@/types/backend";
|
||||||
|
|
||||||
|
type BackendPreferenceState = {
|
||||||
|
backendPreference: BackendPreference;
|
||||||
|
setBackendPreference: (backendPreference: BackendPreference) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useBackendPreferenceStore = create<BackendPreferenceState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
backendPreference: defaultBackendPreference,
|
||||||
|
setBackendPreference: (backendPreference) => set({ backendPreference }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "manager-backend-preference",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -14,6 +14,7 @@ export type Schedule = {
|
|||||||
enter_time: string | null;
|
enter_time: string | null;
|
||||||
day_of_week: DayOfWeek;
|
day_of_week: DayOfWeek;
|
||||||
out_time: string | null;
|
out_time: string | null;
|
||||||
|
shift?: WorkTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WeeklyScheduleResponse = {
|
export type WeeklyScheduleResponse = {
|
||||||
@@ -30,6 +31,7 @@ export type CreateScheduleRequest = {
|
|||||||
export type ClockInRequest = {
|
export type ClockInRequest = {
|
||||||
day_of_week: DayOfWeek;
|
day_of_week: DayOfWeek;
|
||||||
enter_time: string;
|
enter_time: string;
|
||||||
|
shift: WorkTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClockOutRequest = {
|
export type ClockOutRequest = {
|
||||||
|
|||||||
Reference in New Issue
Block a user