Gurudo Geo is a modern work time management and workforce monitoring application focused on location-aware work tracking. It helps employers and employees manage work sessions, track time, and increase productivity while ensuring that work is actually performed at the designated workplace.
In its current version, Gurudo Geo offers:
- Precise geolocation-based access control – employees must be within 100 meters of the assigned workplace to start or end a work session.
- Automated work time tracking – clear, auditable work session history with daily and monthly summaries.
- Subscription model with per-seat billing – the backend counts active users in the organization and applies per-seat billing rules (e.g. base tier up to N users, then additional cost per extra user). Exact pricing and thresholds are fully configurable by the developer (e.g. via environment variables or Stripe dashboard configuration).
- Stripe integration – used to manage subscriptions, billing, and payment methods, and to react to billing events via webhooks.
- Klaviyo integration – used to send emails, track key product and billing events, and support marketing/communication flows.
- Introduction
- Key Features
- Geolocation-Based Work Session Control
- Automated Work Time Tracking
- Real-Time Workforce Monitoring
- Subscription Model with Per-Seat Billing (Stripe)
- Stripe Integration
- Klaviyo Integration
- Flexible Frontend Interface for Employees and Employers
- PDF Reports and Monthly Summaries
- Robust Error Handling and User Feedback
- Architecture Overview
- System Requirements
- Installation Instructions
- Integrations
- Sample Code Components
- Error Management
- Managing Work Sessions
- Manual Testing
- About the Author
- License
- Acknowledgements
Gurudo Geo is a modern, full-stack work time management system designed for companies that require precise, location-based control over employee work sessions. The application provides a complete workflow for tracking working hours, managing workplaces, monitoring employee activity in real time, and enforcing geolocation rules to ensure that work is performed at the correct location.
The platform combines a React-based frontend with a Django REST backend, supported by integrations with Stripe and Klaviyo. This makes Gurudo Geo suitable both as a production-ready tool and as a technical showcase of advanced application architecture, real-time state management, and third-party service integrations.
The core goals of Gurudo Geo include:
-
Providing an accurate, auditable record of employee work sessions.
-
Ensuring that employees can start and end work only within an allowed 100-meter radius of the workplace.
-
Offering employers clear insight into team activity, active sessions, and historical data.
-
Supporting subscription-based billing using a per-seat model fully managed through Stripe.
-
Enabling communication workflows and event-driven email automation through Klaviyo.
This documentation explains the system’s architecture, installation steps, feature logic, integrations, and provides example code components demonstrating how key workflow elements are implemented.
- Landing Page: https://www.gurudo.se
- Web Application: https://app.gurudo.se
-
Gurudo Geo enforces precise location rules to ensure that employees start and end work only where they are supposed to.
-
Employees must be within 100 meters of a defined workplace location to begin or finish a work session.
-
The backend validates GPS coordinates and blocks actions outside the allowed radius.
-
This ensures accurate reporting and prevents misuse or accidental logging from incorrect locations.
The system automatically calculates:
-
Start and end times
-
Total time worked per session
-
Daily summaries
-
Monthly aggregated summaries
Employers gain a transparent, auditable view of work history for each employee.
Employers can monitor their team live, including:
-
Who is currently working
-
Where the employee is working
-
When the session started
-
Total time elapsed so far
This allows for effective supervision across multiple workplaces.
-
Gurudo Geo includes a configurable subscription architecture.
-
The backend counts the number of active users in the organization.
-
Subscription tiers and per-seat pricing are managed via Stripe.
-
Payment methods, invoices, and subscription state changes are handled via Stripe webhooks.
-
The pricing logic is fully configurable by developers (e.g., via environment variables).
The Stripe integration manages:
-
Subscription creation and updates
-
Per-seat billing logic
-
Payment methods
-
Billing cycles and invoices
-
Webhook event handling (e.g., invoice.paid, customer.subscription.updated)
This makes Gurudo Geo production-ready for SaaS monetization.
Gurudo Geo integrates with Klaviyo to support:
-
Email notifications
-
Event-based automations
-
User activity tracking
-
Product onboarding flows
The backend can emit selected events to Klaviyo, enabling scalable communication workflows.
With a React UI built on React Bootstrap and Vite:
-
Employees can start/end sessions, view history, and manage their profile.
-
Employers can manage workplaces, employees, active sessions, and reports.
-
The interface is optimized for both mobile and desktop use.
Employers can generate:
-
Detailed daily breakdowns
-
Monthly summaries
Downloadable PDF reports for record-keeping or payroll processing
The application includes clear error reporting for:
-
Geolocation issues
-
Network errors
-
Form validation problems
-
Session conflicts (e.g., preventing workplace deletion when someone is still working there) Error messages are consistent across backend and frontend.
Gurudo Geo is built as a modular, scalable, full-stack application composed of a Django REST backend, a React frontend, and a set of external service integrations. The architecture is designed to support real-time workforce monitoring, location-based validation, and subscription-based billing.
The backend is built with Django and Django REST Framework, providing a clean and structured API layer.
- JWT-based auth using djangorestframework-simplejwt
- Role separation between employers and employees
- Employees and workplaces
- Work sessions (start, end, validation rules)
- Time aggregation (daily/monthly summaries)
- Active session state tracking
- Ensures employees can only start or end sessions when within 100 meters of the assigned workplace
- Calculates distance using workplace GPS coordinates and client-provided location
- Returns clear validation errors on violations
- Per-seat billing logic
- Counting active users in the organization
- Creating/updating/canceling subscriptions
- Handling Stripe webhook events
- Synchronizing subscription state with the application
- Sends user and billing-related events to Klaviyo
- Supports onboarding and lifecycle email automation
- Digital Ocean Space integration for user profile images
The backend is fully API-driven, enabling future extension (mobile apps, admin dashboards, external integrations).
The frontend is built with React, using Vite for bundling and development performance.
- JWT tokens stored in localStorage
- Automatic token refresh via Axios interceptors
- React Bootstrap components
- Mobile-first dashboard and employee panels
- Multiple views: home, workplaces, employee details, session lists, monthly summaries
- React Router for navigation
- Protected routes based on authentication and user role
- Modals, Alerts, Loaders
- Network state detection
- Error boundaries and API error feedback
- Active session polling
- Live timers
- Immediate UI updates after server changes
The frontend communicates exclusively through backend REST APIs.
The integration manages:
- Subscription lifecycle (create, update, cancel)
- Organization seat counts
- Per-seat pricing rules
- Webhook event handling
- Synchronization of subscription state with the internal user model
Developers can define pricing freely (environment variables or Stripe dashboard settings).
The backend emits selected events to Klaviyo, enabling:
- Activation flows
- Billing notifications
- Usage tracking
- Automated email campaigns
These events are sent through Klaviyo’s API using simple internal service classes.
Each start/end session request passes through:
- Request includes employee GPS coordinates
- Backend loads the workplace’s stored coordinates
- Distance is calculated (Haversine formula or similar)
- If > 100m → request is rejected
- If ≤ 100m → session is valid and processed
This ensures trustworthy location-dependent time tracking.
Data is stored in a relational model aligned with Django ORM:
- Users
- Profiles
- Workplaces (with coordinates)
- Work Sessions (with start/end timestamps)
- Active Sessions
- Billing metadata (Stripe customer IDs, subscription IDs)
The schema is optimized for fast querying of monthly and daily summaries.
The application is designed to run on modern cloud platforms such as Digital Ocean, Heroku, Railway, or AWS.
Typical deployment includes:
- Backend: Django app + Gunicorn
- Frontend: Static build served via cloud hosting
- Database: PostgreSQL
- Media: Digital Ocean Spaces
- Environment Variables: API URLs, Stripe keys, Klaviyo keys, geolocation settings
The architecture enables:
- Adding mobile apps (same REST API)
- Switching Stripe to another billing provider
- Extending geolocation rules (e.g. geofences, multiple points)
- Adding SSO or OAuth
- Integrating company payroll systems
- Multi-organization tenancy
To run and use the Gurudo Geo application, the following system requirements must be met:
Python
- Version: 3.8 or higher
- Python is the primary programming language used to build the backend of the application.
Django
- Version: 3.2 or higher
- Django is the main web framework used to create the backend of the Gurudo Geo application.
Django REST Framework
- Version: 3.12 or higher
- Used to build the API that communicates with the frontend.
djangorestframework-simplejwt
- Used to manage JWT authorization and authentication.
cloudinary
- Version: 1.25.0 or higher
- Used for storing and managing media files in the application.
dj-database-url
- Used to configure the database connection.
Node.js
- Version: 14.x or higher
- Node.js is the JavaScript runtime environment required to run build tools and manage frontend packages.
npm (Node Package Manager)
- Version: 6.x or higher
- npm is used to manage JavaScript packages and frontend libraries.
React
- Version: 18.2.0 or higher
- React is a JavaScript library used to build the user interface.
React Bootstrap
- Version: 2.10.2 or higher
- Used to style frontend components.
React Router
- Version: 6.22.3 or higher
- Used to manage routes in the frontend application.
Vite
- Version: 5.2.0 or higher
- Used as a build tool and to run the frontend application.
PostgreSQL
- Version: 12.x or higher
- PostgreSQL is the recommended database for storing application data.
Docker (optional)
- Used for containerizing the application, which simplifies deployment and environment management.
Visual Studio Code
- Version: latest
- Recommended code editor for working on the project.
Git
- Version: latest
- Version control system used to manage the project's source code.
Chrome DevTools / Safari DevTools
- Tools for debugging the frontend part of the application.
Operating System
- Linux, macOS, Windows (preferably with WSL2 installed for Windows)
Web Browser
- Google Chrome, Mozilla Firefox, Safari (latest version)
Ensuring compliance with the above system requirements will enable the proper functioning and usage of the Gurudo Geo application.
- Open the terminal or command prompt.
- Navigate to the directory where you want to clone the repository.
- Execute the following command to clone the repository (frontend):
git clone https://github.com/lukaszglowacz/gurudo-geo-frontend-public.git
- Navigate to the project directory:
cd gurudo-geo-frontend-public - Execute the following command to clone the repository (backend):
git clone https://github.com/lukaszglowacz/gurudo-geo-backend-public.git
- Navigate to the project directory:
cd gurudo-geo-backend-public
- Ensure that you have Python version 3.8 or later installed.
- Install a virtual environment (venv):
python -m venv venv
- Activate the virtual environment:
- Na systemie Windows:
venv\Scripts\activate
- Na systemie macOS/Linux:
source venv/bin/activate
- Na systemie Windows:
- Install backend dependencies from the
requirements.txtfile:pip install -r requirements.txt
- Ensure you have Node.js version 14.x or newer and npm version 6.x or newer installed.
- Navigate to the frontend directory:
cd gurudo-geo-frontend-public - Install the frontend dependencies:
npm install
- Ensure that the virtual environment is activated.
- Run the database migrations:
python manage.py makemigrations python manage.py migrate
- Start the Django server:
python manage.py runserver
You should see a message indicating that the server is running at http://127.0.0.1:8000/
- Make sure you are in the frontend directory:
cd gurudo-geo-frontend-public - Start the development server:
npm run dev
You should see a message indicating that the application is running at http://localhost:3000/.
The interface for employees and employers differs based on user permissions.
- Employees can record their working hours using the "Start" and "End" buttons.
- Employees can view their work sessions, hour summaries, and historical work session data.
- The total hours worked are displayed, along with basic information needed to monitor working hours during a specific period.
- Employees can edit their personal data and change their password.
- Employers have access to all the functions available to employees.
- Employers can manage workplaces, including adding, deleting, and editing workplace data.
- Employers can manage their employees' work sessions, including adding, editing, and deleting sessions.
- Employers can monitor in real-time which employees are currently active, where they are working, and when they started their work.
- Employers can remotely end an active work session of any employee.
- Employers can generate monthly activity reports for each of their employees in PDF format.
- Employers can permanently delete their own account as well as the accounts of their employees. Employees cannot delete their own accounts.
Employees automatically receive employee permissions upon registering for the application. To register, follow these steps:
- Click the "Sign Up here" button on the login screen.
- Provide the necessary information (email, password, first name, last name, personnummer, company code).
- Click the "Sign Up" button.
- You will be redirected to the login screen.
- Fill in the login form with the registration details and log into the application.
- On the homepage, select the workplace from the dropdown list by clicking the building icon.
- When you are at the workplace and ready to start working, click "Start".
- When you finish working, click "End".
- Go to the "Team Management" section in the navigation menu.
- Select an employee.
- Click the "Show Hours" button.
- You will be taken to the employee's monthly work session view.
- Find the month and day of the session you want to edit.
- Click the "ArrowRight" button next to the date of the work session.
- You will be taken to the daily view of the work sessions.
- Click the "Edit" button.
- Fill out the form with the new data.
- Click the "Save" button.
- Go to the "Team Management" section in the navigation menu.
- Select an employee.
- Click the "Show Hours" button.
- You will be taken to the employee's monthly work session view.
- Click the "Download" button.
- A PDF file with the selected employee's monthly summary for the chosen month will be generated.
- Go to the "Locations" section in the navigation menu.
- Click the "Add" button.
- Fill out the form with detailed information such as street, street number, postal code, and city.
- Click the "Save" button to add a new workplace.
- Employees will now be able to select this workplace from the dropdown menu.
- To edit a workplace, click the "Edit" button and make the necessary changes.
- To delete a workplace, click the "Delete" button and confirm the deletion.
The Gurudo Geo application offers a range of features that simplify time and workplace management. With an intuitive user interface and advanced functionalities, employers and employees can efficiently manage their duties and work hours.
Following the above steps will help you fully utilize the capabilities of the Gurudo Geo application.
- Ability to add, edit, and delete employees.
- Review and manage employee data, including their work session history.
- Adding, editing, and deleting workplaces.
- Review available workplaces and assign them to employee work sessions.
- Employees can start and end work sessions.
- Monitor active work sessions in real time.
- Employers can add, edit, and delete employee work sessions.
- Create reports of employee work sessions.
- Generate monthly summaries of employee activities in PDF format.
- Review employee work sessions by the selected day.
- Detailed information on the start time, end time, and total work time.
- Review employee work sessions by the selected month.
- Summary of total work time for each day of the month.
- Ability to assign roles and permissions for employees and employers.
- Manage access to different application functions based on user roles.
- Display detailed real-time instructions to facilitate navigation and use of the application.
- Notifications of errors, such as an employer attempting to delete a workplace currently in use by an employee.
- Ability to integrate with project management tools and other HR systems.
- API enabling communication with external applications.
- URL:
/ - Method: GET
- Description: This endpoint is the main entry point to the API, typically used to check the status of the API.
- URL:
/admin/ - Method: GET, POST
- Description: Django admin panel, accessible only to administrators. Used for managing application models.
- URL:
/api-auth/ - Method: GET, POST
- Description: Handles user login and logout via the REST API.
- URL:
/profile/ - Method: GET, POST, PUT, DELETE
- Description: Endpoint for managing user profiles. Supports creating, reading, updating, and deleting profiles.
- URL:
/workplace/ - Method: GET, POST, PUT, DELETE
- Description: Endpoint for managing workplaces. Supports creating, reading, updating, and deleting workplaces.
- URL:
/worksession/ - Method: GET, POST, PUT, DELETE
- Description: Endpoint for managing work sessions. Supports creating, reading, updating, and deleting work sessions.
- URL:
/livesession/ - Method: GET
- Description: Endpoint for reading currently active work sessions. Supports only reading.
- URL:
/employee/ - Method: GET, POST, PUT, DELETE
- Description: Endpoint for managing employees. Supports creating, reading, updating, and deleting employees.
-
URL:
/api/token/ -
Method: POST
-
Description: Endpoint for obtaining JWT tokens for logged-in users.
-
URL:
/api/token/refresh/ -
Method: POST
-
Description: Endpoint for refreshing JWT tokens for logged-in users.
- URL:
/register/ - Method: POST
- Description: Endpoint for registering a new user.
- URL:
/password-reset/ - Method: POST, GET
- Description: Endpoint for resetting user passwords. Supports password reset requests and confirmations.
- URL:
/accounts/ - Method: GET, POST, PUT, DELETE
- Description: Endpoint for managing user accounts. Supports creating, reading, updating, and deleting accounts.
Endpoints responsible for managing billing, subscription lifecycle, and Stripe integration. Only employers can create, manage, or synchronize subscriptions.
- URL:
/subscription/checkout/ - Method: POST
- Permissions: Employer only
- Description:
Creates a Stripe Checkout Session allowing the employer to start or update a subscription.
The backend:- ensures the user is an employer,
- creates a Stripe Customer if missing,
- restores cancelled subscriptions when possible,
- calculates current active user count (per-seat billing),
- generates a Stripe checkout session URL.
- URL:
/subscription/verify/<session_id>/ - Method: GET
- Permissions: Authenticated employer
- Description:
Verifies a completed Stripe Checkout Session.
Retrieves subscription details from Stripe and updates or creates a localSubscriptionrecord.
- URL:
/subscription/details/ - Method: GET
- Permissions: Authenticated user
- Description:
Returns detailed subscription data, including:- local subscription state,
- upcoming invoice information,
- seat quantity (active users),
- cancellation schedule (
cancel_at), - trial status (if applicable).
- URL:
/subscription/webhook/ - Method: POST
- Permissions: Public (Stripe signature required)
- Description:
Stripe calls this endpoint to deliver events such as:checkout.session.completed— subscription created/updated,invoice.upcoming— used to validate or sync seat counts,- other subscription lifecycle events.
The backend validates the Stripe signature and updates the local database accordingly.
- URL:
/subscription/portal/ - Method: POST
- Permissions: Employer only
- Description:
Creates a Stripe Billing Portal session where employers can:- manage payment methods,
- access invoices,
- view billing history,
- manage their subscription.
Returns a redirect URL to the Stripe Billing Portal.
- URL:
/subscription/sync/ - Method: POST
- Permissions: Employer only
- Description:
Forces a manual synchronization of subscription data between Stripe and the local database, including:- subscription status,
- seat quantity (active employees),
- cancellation flags and trial periods.
- URL:
/subscription/status/ - Method: GET
- Permissions: Authenticated user
- Description:
Returns a compact subscription summary:- whether a subscription exists,
- whether it is currently active,
- whether it is cancelled with a future end date.
To enable communication between the frontend and backend, configure CORS in the settings.py file:
# settings.py
INSTALLED_APPS = [
...,
'corsheaders',
...,
]
MIDDLEWARE = [
...,
'corsheaders.middleware.CorsMiddleware',
...,
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOWED_ORIGINS = [
'https://appname-front.example.com',
'https://appname-back.example.com',
]The api.ts file contains the Axios configuration, including setting the baseURL for HTTP requests to the backend:
import axios, { AxiosInstance, AxiosError, AxiosResponse, AxiosRequestConfig } from "axios";
const api: AxiosInstance = axios.create({
baseURL: "https://api.company.com", // Replace with your actual API base URL
headers: {
"Content-Type": "application/json",
},
});
interface CustomAxiosRequestConfig extends AxiosRequestConfig {
retry?: boolean;
}
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem("accessToken");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error: AxiosError) => Promise.reject(error)
);
api.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: AxiosError) => {
const originalRequest = error.config as CustomAxiosRequestConfig;
// Do not refresh the token solely for login or token refresh actions.
if (originalRequest.url?.includes("/api/token/")) {
return Promise.reject(error);
}
if (
error.response &&
error.response.status === 401 &&
!originalRequest.retry
) {
originalRequest.retry = true;
try {
const tokenResponse = await axios.post<{ access: string }>("https://api.company.com/api/token/refresh/", {
refresh: localStorage.getItem("refreshToken")
});
if (tokenResponse.data.access) {
localStorage.setItem("accessToken", tokenResponse.data.access);
if (originalRequest.headers) {
originalRequest.headers['Authorization'] = `Bearer ${tokenResponse.data.access}`;
}
return api(originalRequest);
}
} catch (refreshError: any) {
console.error("Error refreshing token:", refreshError);
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default api;Gurudo Geo integrates with several external services to support subscription billing, communication workflows, and location-aware work session validation. This section describes the high-level architecture and logic behind these integrations so that developers can easily adapt or extend them in their own environments.
Stripe is used to manage subscription billing in a per-seat model. The public version of the project focuses on the architecture and flow, while exact pricing and plan configuration are left to the developer (e.g., via environment variables or Stripe dashboard setup).
- Each employer (account owner) is treated as a customer in Stripe.
- The number of active users in the organization is used to determine the subscription cost (per-seat pricing).
- The backend periodically or on-demand counts active users in the organization.
- This count is sent to Stripe as the quantity for a subscription or subscription item.
- Pricing tiers, base user count, and additional user cost are defined in Stripe, not hard-coded in the app.
- The backend stores references to Stripe
customer_idandsubscription_idin the database. - When an employer upgrades, downgrades, or cancels, the backend updates the subscription in Stripe and synchronizes the state locally.
Gurudo Geo listens to Stripe webhooks to react to important billing events, such as:
invoice.paid– subscription continues as normal.invoice.payment_failed– subscription may be marked as past due, with limited access in the app.customer.subscription.updated– quantity or plan changes.customer.subscription.deleted– subscription cancelled.
The webhook endpoint:
- Validates the Stripe signature.
- Parses the event type.
- Updates the internal subscription state accordingly (e.g., enabling/disabling premium features).
All Stripe-related settings (API keys, product IDs, price IDs, webhook secret, base tier thresholds) are provided via environment variables or Stripe dashboard configuration. The repository focuses on the integration pattern, not specific commercial terms.
Klaviyo is used to send email campaigns and track important product and billing events. Gurudo Geo does not hard-code complex flows, but exposes a simple event layer that can be expanded as needed.
The backend emits events to Klaviyo when meaningful actions happen, for example:
- New employer or employee account created.
- Subscription created, updated, or cancelled.
- Important product milestones (e.g., first team member added, first month closed, unusual usage patterns – if implemented).
Each event typically includes:
- User identifiers (email, internal user ID).
- Organization identifiers (if applicable).
- Event type (e.g.,
subscription_started,subscription_cancelled,user_registered). - Optional metadata (e.g., current plan name, number of seats, timestamp).
A small service layer is responsible for:
- Formatting events into the structure expected by Klaviyo.
- Sending them using the Klaviyo API and API key from environment variables.
- Handling and logging any failures (without blocking the main application flow).
Once events are in Klaviyo, they can be used to:
- Trigger onboarding sequences for new employers.
- Send billing reminders and subscription status notifications.
- Analyze user engagement and product adoption.
The public codebase demonstrates where and how to emit events; the specific flows and email templates are fully customizable on the Klaviyo side.
Geolocation is a core part of Gurudo Geo, ensuring that work sessions are only started and ended at the correct workplace.
- When an employee attempts to start or end a work session, the frontend requests the user’s current location via the browser’s Geolocation API (with user consent).
- Latitude and longitude coordinates are sent to the backend together with the start/end request.
- Each workplace has stored coordinates (latitude and longitude) in the database.
- These are defined by the employer when configuring the workplace (e.g., via an address that is geocoded or directly input as coordinates).
- The backend calculates the distance between:
- The employee’s current coordinates, and
- The workplace coordinates.
- A standard formula (e.g. Haversine) or equivalent distance function is used.
- If the distance is greater than 100 meters, the backend:
- Rejects the start/end request.
- Returns a descriptive error message to the frontend (e.g. “You are too far from the workplace to start/end this session.”).
- If the distance is within 100 meters, the request is accepted and the work session is started or ended as usual.
- If the user denies location access, or the location cannot be determined, the frontend displays a clear error and does not attempt to bypass the check.
- All geolocation validation is performed on the server side to prevent manipulation of location logic in the browser.
- The allowed radius (100m) can be made configurable via environment variables or constants, allowing developers to adapt Gurudo Geo to different business requirements or testing scenarios.
These geolocation rules ensure that time tracking is tightly coupled to physical presence at the workplace, which is especially important for field teams, on-site jobs, and compliance-sensitive environments.
This section presents code examples for the main components of the application, demonstrating how various parts of the application are built. These components are crucial for the functionality of the application and illustrate how different technologies and libraries are used to implement specific features.
The EmployeeDetailsByDay.tsx component is responsible for displaying detailed information about an employee's work sessions on a selected day for the employer. Below is the sample code for this component.
import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import api from "../api/api";
import { WorkSession, Employee } from "../api/interfaces/types";
import {
Container,
Row,
Col,
Alert,
ListGroup,
Button,
Card,
} from "react-bootstrap";
import {
House,
ClockHistory,
ClockFill,
HourglassSplit,
PersonBadge,
Envelope,
PersonCircle,
ChevronLeft,
ChevronRight,
PlusSquare,
PencilSquare,
Trash,
Sticky,
} from "react-bootstrap-icons";
import { sumTotalTime } from "../utils/timeUtils";
import { formatTime } from "../utils/dateUtils";
import Loader from "./Loader";
import LoadingButton from "./LoadingButton";
import moment from "moment-timezone";
import ConfirmModal from "./ConfirmModal";
import "moment/locale/sv";
import styles from "./styles/StableApp.module.css";
import { useToast } from "../context/ToastContext";
moment.locale("sv"); // Setting the language to Swedish
const EmployeeDetailsByDay: React.FC = () => {
const { id, date } = useParams<{ id: string; date?: string }>();
const [employee, setEmployee] = useState<Employee | null>(null);
const [sessions, setSessions] = useState<WorkSession[]>([]);
const [totalTime, setTotalTime] = useState<string>("0 tim, 0 min");
const [isLoadingSessions, setIsLoadingSessions] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState<boolean>(false);
const [sessionToDelete, setSessionToDelete] = useState<number | null>(null);
const navigate = useNavigate();
const { showToast } = useToast();
useEffect(() => {
const fetchEmployee = async () => {
try {
const response = await api.get<Employee>(`/employee/${id}`);
setEmployee(response.data);
} catch (err) {
setError("Error retrieving employee data");
}
};
fetchEmployee();
}, [id]);
useEffect(() => {
if (employee) {
fetchSessions(employee.work_session, date);
}
}, [employee, date]);
const fetchSessions = (allSessions: WorkSession[], date?: string) => {
setIsLoadingSessions(true);
const daySessions = getSessionsForDate(allSessions, date);
setSessions(daySessions);
setTotalTime(sumTotalTime(daySessions));
setIsLoadingSessions(false);
};
const getSessionsForDate = (sessions: WorkSession[], date?: string) => {
if (!date) return [];
const targetDate = moment.tz(date, "Europe/Stockholm");
const sessionsForDate: WorkSession[] = [];
sessions.forEach((session) => {
const start = moment.utc(session.start_time).tz("Europe/Stockholm");
const end = moment.utc(session.end_time).tz("Europe/Stockholm");
let currentStart = start.clone();
while (currentStart.isBefore(end)) {
const sessionEndOfDay = currentStart.clone().endOf("day");
const sessionEnd = end.isBefore(sessionEndOfDay)
? end
: sessionEndOfDay;
if (currentStart.isSame(targetDate, "day")) {
sessionsForDate.push({
...session,
start_time: currentStart.toISOString(),
end_time: sessionEnd.toISOString(),
total_time: calculateTotalTime(currentStart, sessionEnd),
});
} else if (
currentStart.isBefore(targetDate) &&
sessionEnd.isAfter(targetDate)
) {
const fullDaySessionStart = targetDate.clone().startOf("day");
const fullDaySessionEnd = targetDate.clone().endOf("day");
sessionsForDate.push({
...session,
start_time: fullDaySessionStart.toISOString(),
end_time: fullDaySessionEnd.toISOString(),
total_time: calculateTotalTime(
fullDaySessionStart,
fullDaySessionEnd
),
});
}
currentStart = sessionEnd.clone().add(1, "second");
}
});
return sessionsForDate;
};
const calculateTotalTime = (
start: moment.Moment,
end: moment.Moment
): string => {
const duration = moment.duration(end.diff(start));
const hours = Math.floor(duration.asHours());
const minutes = duration.minutes();
return `${hours} tim, ${minutes} min`;
};
const changeDay = (offset: number): void => {
if (!date) {
console.error("Date not available");
return;
}
const currentDate = moment.tz(date, "Europe/Stockholm").add(offset, "days");
navigate(`/employee/${id}/day/${currentDate.format("YYYY-MM-DD")}`);
};
const handleEditSession = async (sessionId: number) => {
navigate(`/edit-work-hour/${sessionId}?date=${date}&employeeId=${id}`);
};
const handleDeleteSession = async (sessionId: number) => {
setShowModal(true);
setSessionToDelete(sessionId);
};
const confirmDeleteSession = async () => {
if (sessionToDelete !== null) {
try {
await api.delete(`/worksession/${sessionToDelete}`);
const updatedSessions = sessions.filter(
(session) => session.id !== sessionToDelete
);
setSessions(updatedSessions);
setTotalTime(sumTotalTime(updatedSessions));
setShowModal(false);
setSessionToDelete(null);
showToast("Arbetstid har raderats", "success");
} catch (error) {
console.error("Error deleting session: ", error);
setError("Error deleting session");
showToast("Det gick inte att radera arbetstid", "error");
setShowModal(false);
setSessionToDelete(null);
}
}
};
const handleAddSession = async () => {
navigate(`/add-work-hour?date=${date}&employeeId=${id}`);
};
const formatWorkplaceAddress = (workplace: {
street: string | null;
street_number: string | null;
postal_code: string | null;
city: string | null;
}): string => {
const { street, street_number, postal_code, city } = workplace;
const parts = [
[street, street_number].filter(Boolean).join(" "),
postal_code,
city,
].filter(Boolean); // removes null, undefined, ""
return parts.join(", ");
};
return (
<Container className={styles.pageUnderNavbar}>
<Row className="justify-content-center my-3">
<Col md={6} className=" p-0">
<LoadingButton
variant="primary"
onClick={handleAddSession}
icon={PlusSquare}
title="Lägg till"
size={24}
label="Lägg till"
/>
</Col>
</Row>
<Row className="justify-content-center mt-3">
<Col md={6} className="p-0">
<Card className="mt-3 mb-3">
<Card.Body>
<Card.Text className="small text-muted">
<PersonCircle className="me-2" />
{employee?.full_name}
</Card.Text>
<Card.Text className="small text-muted">
<PersonBadge className="me-2" />
{employee?.personnummer}
</Card.Text>
<Card.Text className="small text-muted">
<Envelope className="me-2" />
{employee?.user_email}
</Card.Text>
<Card.Text className="small text-muted">
<HourglassSplit className="me-2" />
<span style={{ fontWeight: 600 }}>{totalTime}</span>
</Card.Text>
</Card.Body>
</Card>
</Col>
</Row>
<Row className="justify-content-center my-3">
<Col md={6} className="p-0">
<Row className="justify-content-between">
<Col className="text-start">
<Button
className="btn-lg p-0 m-0"
onClick={() => changeDay(-1)}
variant="outline-success"
style={{ border: "none" }}
>
<ChevronLeft />
</Button>
</Col>
<Col className="text-center">
{date ? (
<>
<div
className="font-weight-bold"
style={{ fontSize: "15px" }}
>
{moment.tz(date, "Europe/Stockholm").format("D MMMM YYYY")}
</div>
<small className="text-muted">
{moment.tz(date, "Europe/Stockholm").format("dddd")}
</small>
</>
) : (
<span>Tid ej tillgänglig</span>
)}
</Col>
<Col className="text-end">
<Button
className="btn-lg p-0 m-0"
onClick={() => changeDay(1)}
variant="outline-success"
style={{ border: "none" }}
>
<ChevronRight />
</Button>
</Col>
</Row>
</Col>
</Row>
{isLoadingSessions && (
<Row className="justify-content-center my-5">
<Col md={6} className="text-center p-0">
<Loader />
</Col>
</Row>
)}
{!isLoadingSessions && !error && !sessions.length && (
<Row className="justify-content-center my-3">
<Col md={6} className="text-center p-0">
<Alert variant="info" className="text-center">
Inga tidspårningar
</Alert>
</Col>
</Row>
)}
{!isLoadingSessions && !error && sessions.length > 0 && (
<ListGroup className="my-3">
{sessions.map((session) => (
<Row key={session.id} className="justify-content-center">
<Col md={6} className="p-0">
<ListGroup.Item className="mb-2">
<Row className="align-items-center">
<Col xs={12} className="small">
<House className="me-2" />
{formatWorkplaceAddress(session.workplace)}
</Col>
<Col xs={12} className="small">
<ClockFill className="me-2" />{" "}
{formatTime(session.start_time)}
</Col>
<Col xs={12} className="small">
<ClockHistory className="me-2" />{" "}
{formatTime(session.end_time)}
</Col>
<Col xs={12} className="small">
<HourglassSplit className="me-2" /> {session.total_time}
</Col>
<Col xs={12} className="small">
<Sticky className="me-2" />{" "}
{session.notes || "Inga anteckningar tillgängliga"}
</Col>
</Row>
<Row>
<Col xs={12}>
<div className="d-flex flex-column gap-1 mt-3">
<LoadingButton
variant="success"
onClick={() => handleEditSession(session.id)}
icon={PencilSquare}
title="Redigera"
size={24}
label="Redigera"
/>
<LoadingButton
variant="outline-danger"
onClick={() => handleDeleteSession(session.id)}
icon={Trash}
title="Radera"
size={24}
label="Radera"
/>
</div>
</Col>
</Row>
</ListGroup.Item>
</Col>
</Row>
))}
</ListGroup>
)}
<ConfirmModal
show={showModal}
onHide={() => setShowModal(false)}
onConfirm={confirmDeleteSession}
>
Confirm deletion of this work session
</ConfirmModal>
</Container>
);
};
export default EmployeeDetailsByDay;
Managing errors in the Gurudo Geo application is a crucial aspect of ensuring a high-quality user experience. This section describes how to handle errors to keep the application running smoothly and user-friendly.
In the Gurudo Geo application, error messages are displayed using the Alert components from the React Bootstrap library. Error messages inform the user about issues that have occurred while using the application and suggest possible corrective actions.
import React, { useState } from 'react';
import { Alert } from 'react-bootstrap';
const ErrorNotification = ({ errorMessage }) => {
return (
<Alert variant="danger">
{errorMessage}
</Alert>
);
};
export default ErrorNotification;The Gurudo Geo application uses the Axios library to perform HTTP requests. Network error handling is implemented by intercepting errors using Axios interceptors.
import axios, { AxiosError, AxiosResponse } from 'axios';
const api = axios.create({
baseURL: 'http://localhost:8000',
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.response.use(
(response: AxiosResponse) => response,
(error: AxiosError) => {
if (error.response) {
// W przypadku błędów serwera, wyświetl komunikat o błędzie
console.error('Server Error:', error.response.data);
} else if (error.request) {
// W przypadku braku odpowiedzi serwera, wyświetl komunikat o błędzie
console.error('Network Error:', error.request);
} else {
// Inne błędy
console.error('Error:', error.message);
}
return Promise.reject(error);
}
);
export default api;Proper error handling is essential for ensuring the stability and usability of the application. Employing the techniques mentioned above helps in identifying, diagnosing, and resolving issues, leading to a better user experience.
Managing work sessions is a key aspect of the Gurudo Geo application. Below is a description of how the application handles various aspects of work session management, including current sessions, deleting workplaces when there are active sessions, and other related operations.
The Gurudo Geo application tracks current work sessions of employees, providing real-time insight into ongoing activity and workplace presence. Each employee can start and end a work session using the "Start" and "End" buttons on the main page, but only if they are physically located within the required 100-meter radius of the assigned workplace.
Before a session is started or ended, the application collects the employee’s current GPS location and validates it on the backend. If the employee is outside the allowed radius or location access is denied, the action is blocked and a clear error message is shown.
All active sessions, including the associated workplace and timestamps, are stored on the backend and can be reviewed by employers to monitor employee activity, verify workplace presence, and ensure accurate time tracking.
import React, { useState, useEffect } from "react";
import { Button, Alert, Container, Modal, Form } from "react-bootstrap";
import { useMap } from "@vis.gl/react-google-maps";
import api from "../api/api";
import { useAuth } from "../context/AuthContext";
import WorkplaceSelector from "./WorkplaceSelector";
import ConfirmModal from "./ConfirmModal";
import Loader from "./Loader";
import LoadingButton from "./LoadingButton";
import { Save2, XCircle, GeoAltFill } from "react-bootstrap-icons";
import styles from "./styles/StableApp.module.css";
import { useSafePosition } from "../hooks/geolocation/useSafePosition";
import { useToast } from "../context/ToastContext";
interface Profile {
id: number;
user_email: string;
user_id: number;
full_name: string;
first_name: string;
last_name: string;
personnummer: string;
created_at: string;
updated_at: string;
image: string;
}
interface Workplace {
id: number;
street: string;
street_number: string;
postal_code: string;
city: string;
latitude?: number;
longitude?: number;
}
interface Session {
id: number;
profile: Profile;
workplace: Workplace;
start_time: string;
status: string;
}
const Home: React.FC = () => {
const { profileId } = useAuth();
const [workplaces, setWorkplaces] = useState<Workplace[]>([]);
const [selectedWorkplaceId, setSelectedWorkplaceId] = useState<number>(0);
const [activeSession, setActiveSession] = useState<Session | null>(null);
const [isActiveSession, setIsActiveSession] = useState(false);
const [showModal, setShowModal] = useState(false);
const [modalText, setModalText] = useState("");
const [modalAction, setModalAction] = useState<() => void>(() => {});
const [loading, setLoading] = useState(true);
const [showNotesModal, setShowNotesModal] = useState(false);
const [notes, setNotes] = useState<string>("");
const [initialUserIframeSrc, setInitialUserIframeSrc] = useState<
string | null
>(null);
const [selectedWorkplacePosition, setSelectedWorkplacePosition] = useState<
[number, number] | null
>(null);
const [tooFar, setTooFar] = useState<boolean>(false);
const map = useMap();
const { userPosition, locationError, locationErrorMessage } =
useSafePosition();
const calculateDistance = (
pos1: [number, number],
pos2: [number, number]
) => {
const toRad = (value: number) => (value * Math.PI) / 180;
const R = 6371000;
const dLat = toRad(pos2[0] - pos1[0]);
const dLon = toRad(pos2[1] - pos1[1]);
const lat1 = toRad(pos1[0]);
const lat2 = toRad(pos2[0]);
const a =
Math.sin(dLat / 2) ** 2 +
Math.sin(dLon / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
const { showToast } = useToast();
useEffect(() => {
// ustaw mapę tylko raz przy pierwszym userPosition
if (userPosition && !initialUserIframeSrc) {
const url = `https://www.google.com/maps/embed/v1/place?key={import.meta.env.VITE_GOOGLE_MAPS_API_KEY}¢er=${userPosition[0]},${userPosition[1]}&zoom=17&q=${userPosition[0]},${userPosition[1]}`;
setInitialUserIframeSrc(url);
}
}, [userPosition, initialUserIframeSrc]);
useEffect(() => {
if (!selectedWorkplacePosition || !userPosition) return;
const distance = calculateDistance(userPosition, selectedWorkplacePosition);
setTooFar(distance > 100);
}, [userPosition, selectedWorkplacePosition]);
useEffect(() => {
if (locationError) {
setTooFar(true);
}
}, [locationError]);
useEffect(() => {
if (!map) return;
if (selectedWorkplacePosition) {
map.panTo({
lat: selectedWorkplacePosition[0],
lng: selectedWorkplacePosition[1],
});
map.setZoom(17);
} else if (userPosition) {
map.panTo({ lat: userPosition[0], lng: userPosition[1] });
map.setZoom(17);
}
}, [selectedWorkplacePosition, userPosition, map]);
const handleSelectWorkplace = (id: number) => {
setSelectedWorkplaceId(id);
const selectedWp = workplaces.find((wp) => wp.id === id);
if (selectedWp?.latitude && selectedWp?.longitude) {
setSelectedWorkplacePosition([selectedWp.latitude, selectedWp.longitude]);
}
};
useEffect(() => {
const fetchData = async () => {
try {
const workplacesRes = await api.get("/workplace/");
const updated = workplacesRes.data.map((wp: Workplace) => ({
...wp,
location:
wp.latitude && wp.longitude
? { lat: wp.latitude, lng: wp.longitude }
: undefined,
}));
setWorkplaces(updated);
const sessionRes = await api.get("/livesession/active/");
const userSession = sessionRes.data.find(
(s: Session) => s.profile.id === Number(profileId)
);
if (userSession) {
setActiveSession(userSession);
setIsActiveSession(true);
setSelectedWorkplaceId(userSession.workplace.id);
if (
userSession.workplace.latitude &&
userSession.workplace.longitude
) {
setSelectedWorkplacePosition([
userSession.workplace.latitude,
userSession.workplace.longitude,
]);
}
}
} catch (err) {
console.error("Error fetching data:", err);
} finally {
setLoading(false);
}
};
fetchData();
}, [profileId]);
const handleStartSession = () => {
if (!profileId || selectedWorkplaceId <= 0 || activeSession) return;
setModalText("Starta arbetet?");
setModalAction(() => startSession);
setShowModal(true);
};
const startSession = async () => {
setShowModal(false);
try {
const res = await api.post("/livesession/start/", {
workplace: selectedWorkplaceId,
profile: profileId,
});
setActiveSession(res.data);
setIsActiveSession(true);
showToast("Arbetet har startat", "success");
} catch {
showToast("Kunde inte starta arbetet", "error");
}
};
const handleEndSession = () => {
if (!activeSession?.id) return;
setModalText("Avsluta arbetet?");
setModalAction(() => endSession);
setShowModal(true);
};
const endSession = async () => {
if (!activeSession) return;
setShowModal(false);
setShowNotesModal(true);
};
const handleSaveNotes = async () => {
try {
await api.patch(`/livesession/end/${activeSession!.id}/`, { notes });
setActiveSession(null);
setIsActiveSession(false);
setSelectedWorkplaceId(0);
setSelectedWorkplacePosition(null);
setShowNotesModal(false);
setNotes("");
showToast("Arbetet har avslutats", "success");
} catch {
console.error("Error saving notes and ending session");
showToast("Kunde inte avsluta arbetet", "error");
}
};
useEffect(() => {
document.body.classList.add(styles.bodyNoScroll);
return () => document.body.classList.remove(styles.bodyNoScroll);
}, []);
useEffect(() => {
if (!CSS.supports("height", "100dvh")) {
document.body.style.overflowY = "auto";
}
}, []);
if (loading) return <Loader />;
return (
<Container className={`${styles.homeSession} ${styles.bodyNoScroll} p-0`}>
<div className={styles.homeSelector}>
<WorkplaceSelector
workplaces={workplaces}
selectedWorkplaceId={selectedWorkplaceId}
onSelect={handleSelectWorkplace}
isActiveSession={isActiveSession}
/>
</div>
<div className={styles.homeMapContainer}>
{selectedWorkplacePosition ? (
<iframe
width="100%"
height="100%"
style={{ border: 0 }}
loading="lazy"
allowFullScreen
referrerPolicy="no-referrer-when-downgrade"
src={`https://www.google.com/maps/embed/v1/place?key={import.meta.env.VITE_GOOGLE_MAPS_API_KEY}¢er=${selectedWorkplacePosition[0]},${selectedWorkplacePosition[1]}&zoom=17&q=${selectedWorkplacePosition[0]},${selectedWorkplacePosition[1]}`}
/>
) : initialUserIframeSrc ? (
<iframe
width="100%"
height="100%"
style={{ border: 0 }}
loading="lazy"
allowFullScreen
referrerPolicy="no-referrer-when-downgrade"
src={initialUserIframeSrc}
/>
) : null}
{(!userPosition || locationError) && (
<div
className="position-absolute top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center"
style={{ backgroundColor: "rgba(255,255,255,0.8)", zIndex: 10 }}
>
<div className="text-center d-flex flex-column align-items-center gap-2">
<GeoAltFill
size={36}
className={`${styles.pulsingIcon} text-danger`}
/>
<span className="fw-medium text-danger">
Hämtar din position...
</span>
</div>
</div>
)}
</div>
{locationError ? (
<Alert
variant="danger"
className="text-center mt-2 mb-0 w-100 p-2"
style={{ maxWidth: "768px" }}
>
{locationErrorMessage || "Kunde inte hämta plats"}
<div className="mt-2">
<a
href="https://support.google.com/chrome/answer/142065?hl=sv"
target="_blank"
rel="noopener noreferrer"
className="text-danger text-decoration-underline fw-medium"
>
Hur aktiverar jag plats?
</a>
</div>
</Alert>
) : (
userPosition &&
selectedWorkplacePosition && (
<Alert
variant={tooFar ? "danger" : "success"}
className="text-center mt-2 mb-0 w-100 p-2"
style={{ maxWidth: "768px" }}
>
Du är{" "}
<strong>{`${calculateDistance(
userPosition,
selectedWorkplacePosition
)
.toFixed(0)
.toLocaleString()} m`}</strong>{" "}
från arbetsplatsen
</Alert>
)
)}
<div className={styles.homeButtonContainer}>
{!activeSession ? (
selectedWorkplacePosition ? (
<Button
variant="success"
onClick={handleStartSession}
disabled={tooFar || locationError}
className="btn-lg w-100"
style={{ padding: "15px 25px", fontSize: "1.5rem", fontWeight: "400" }}
>
Börjar nu
</Button>
) : (
userPosition && (
<Button
variant="light"
onClick={() => {
const url = `https://www.google.com/maps/embed/v1/place?key={import.meta.env.VITE_GOOGLE_MAPS_API_KEY}¢er=${userPosition[0]},${userPosition[1]}&zoom=17&q=${userPosition[0]},${userPosition[1]}`;
setInitialUserIframeSrc(url);
showToast("Positionen har uppdaterats", "success");
}}
className="btn-lg w-100 d-flex align-items-center justify-content-center gap-2"
style={{ padding: "15px 25px", fontSize: "1.2rem", fontWeight: "400" }}
>
<GeoAltFill className="text-primary" size={20} />
Uppdatera position
</Button>
)
)
) : (
<Button
variant="danger"
onClick={handleEndSession}
disabled={tooFar || locationError}
className="btn-lg w-100"
style={{ padding: "15px 25px", fontSize: "1.5rem", fontWeight: "400", borderWidth: "3px" }}
>
Avsluta
</Button>
)}
</div>
<ConfirmModal
show={showModal}
onHide={() => setShowModal(false)}
onConfirm={modalAction}
>
{modalText}
</ConfirmModal>
<Modal
show={showNotesModal}
onHide={() => setShowNotesModal(false)}
centered
>
<Modal.Header closeButton>
<Modal.Title>Lägg till anteckning</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form>
<Form.Group controlId="notes">
<Form.Control
as="textarea"
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Vänligen lägg till viktiga anteckningar eller detaljer om dagens arbetspass"
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<div className="d-flex justify-content-around w-100 mt-3">
<LoadingButton
variant="success"
onClick={handleSaveNotes}
icon={Save2}
title="Spara"
size={24}
label="Spara"
/>
<LoadingButton
variant="danger"
onClick={async () => {
await Promise.resolve();
setShowNotesModal(false);
}}
icon={XCircle}
title="Avbryt"
size={24}
label="Avbryt"
/>
</div>
</Modal.Footer>
</Modal>
</Container>
);
};
export default Home;To ensure data integrity, the application does not allow the deletion of a workplace if there are active work sessions associated with it. Before deleting a workplace, the application checks to ensure that no employees are currently logged in and working at that location.
import React, { useState, useEffect } from "react";
import { Container, Row, Col, Card, Alert } from "react-bootstrap";
import { useNavigate } from "react-router-dom";
import { useUserProfile } from "../context/UserProfileContext";
import { useAuth } from "../context/AuthContext";
import Loader from "./Loader";
import { PencilSquare, PlusSquare, Trash } from "react-bootstrap-icons";
import ConfirmModal from "./ConfirmModal";
import api from "../api/api";
import { IWorkPlacesData } from "../api/interfaces/types";
import LoadingButton from "./LoadingButton";
import styles from "./styles/StableApp.module.css";
import { useLocation } from "react-router-dom";
import { useToast } from "../context/ToastContext";
const WorkPlaceContainer: React.FC = () => {
const [workplaces, setWorkplaces] = useState<IWorkPlacesData[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState<boolean>(false);
const [workplaceToDelete, setWorkplaceToDelete] = useState<number | null>(
null
);
const [errorMap, setErrorMap] = useState<{ [key: number]: string | null }>(
{}
);
const navigate = useNavigate();
const location = useLocation();
const { showToast } = useToast();
const { profile, companyProfile, loadProfile, loadCompanyProfile } =
useUserProfile();
const { isAuthenticated, isEmployer } = useAuth();
useEffect(() => {
const toast = location.state?.toast;
if (toast?.message) {
showToast(toast.message, toast.type || "info");
// 🔧 usuń toast ze state przez .navigate z replace:true
navigate(location.pathname, { replace: true });
}
}, [location, showToast, navigate]);
useEffect(() => {
const fetchProfile = async () => {
if (isEmployer) {
await loadCompanyProfile();
} else {
await loadProfile();
}
setLoading(false);
};
fetchProfile();
}, [isEmployer, loadProfile, loadCompanyProfile]);
const fetchWorkplaces = async () => {
try {
setLoading(true);
const response = await api.get<IWorkPlacesData[]>("/workplace/");
setWorkplaces(response.data);
setLoading(false);
} catch (error) {
console.error("Unable to load workplaces", error);
setLoading(false);
}
};
useEffect(() => {
fetchWorkplaces();
}, []);
const handleAddClick = async () => {
navigate("/add-work-place");
};
const handleEditClick = async (id: number) => {
navigate(`/edit-work-place/${id}`);
};
const handleDeleteClick = async (id: number) => {
setShowModal(true);
setWorkplaceToDelete(id);
setErrorMap((prev) => ({ ...prev, [id]: null }));
};
const confirmDeleteWorkplace = async () => {
if (workplaceToDelete !== null) {
try {
await api.delete(`/workplace/${workplaceToDelete}/`);
showToast("Arbetsplatsen har raderats", "success");
fetchWorkplaces();
setShowModal(false);
} catch (error: unknown) {
console.error("Unable to delete workplace", error);
showToast("Kan inte ta bort arbetsplatsen – den används just nu.", "error");
setShowModal(false);
setErrorMap((prev) => ({
...prev,
[workplaceToDelete]: "Unable to delete workplace",
}));
}
}
};
if (loading || (!profile && !companyProfile)) {
return <Loader />;
}
return (
<Container className={styles.pageUnderNavbar}>
{isAuthenticated && (
<Row className="justify-content-center">
<Col md={6} className="p-0">
{isEmployer && companyProfile && (
<LoadingButton
variant="primary"
onClick={handleAddClick}
icon={PlusSquare}
title="Lägg till arbetsplats"
size={24}
label="Lägg till arbetsplats"
/>
)}
{workplaces.length === 0 && (
<Alert variant="info" className="w-100 text-center mt-3">
Inga arbetsplatser tillgängliga
</Alert>
)}
</Col>
</Row>
)}
<Row className="justify-content-center mt-3">
<Col md={6} className="p-0">
{workplaces.map((workplace) => (
<Card className="mb-4" key={workplace.id}>
<Card.Header className="text-center">
<div className="d-flex flex-column justify-content-center">
<span>
{workplace.street && workplace.street_number
? `${workplace.street} ${workplace.street_number}`
: workplace.street || workplace.street_number}
</span>
<span>
{workplace.postal_code && workplace.city
? `${workplace.postal_code} ${workplace.city}`
: workplace.postal_code || workplace.city}
</span>
</div>
</Card.Header>
<Card.Body className="p-0">
{workplace.latitude !== null && workplace.longitude !== null ? (
<>
<iframe
width="100%"
height="300"
style={{ border: 0 }}
loading="lazy"
allowFullScreen
referrerPolicy="no-referrer-when-downgrade"
src={`https://www.google.com/maps/embed/v1/place?key={import.meta.env.VITE_GOOGLE_MAPS_API_KEY}¢er=${workplace.latitude},${workplace.longitude}&zoom=17&q=${workplace.latitude},${workplace.longitude}`}
></iframe>
{isEmployer && companyProfile && (
<>
<div className="d-flex flex-column px-3 py-2">
<div className="w-100">
<LoadingButton
variant="success"
onClick={() => handleEditClick(workplace.id)}
icon={PencilSquare}
title="Ändra arbetsplats"
size={24}
label="Ändra"
/>
</div>
<div className="w-100">
<LoadingButton
variant="outline-danger"
onClick={() => handleDeleteClick(workplace.id)}
icon={Trash}
title="Radera arbetsplats"
size={24}
label="Radera"
/>
</div>
</div>
{errorMap[workplace.id] && (
<Alert variant="warning" className="mt-3 text-center">
{errorMap[workplace.id]}
</Alert>
)}
</>
)}
</>
) : (
<p>Ingen platsdata tillgänglig</p>
)}
</Card.Body>
</Card>
))}
</Col>
</Row>
<ConfirmModal
show={showModal}
onHide={() => setShowModal(false)}
onConfirm={confirmDeleteWorkplace}
>
Är du säker på att du vill radera denna arbetsplats?
</ConfirmModal>
</Container>
);
};
export default WorkPlaceContainer;Employers have access to view the current activity of their employees. They can see which employees are currently logged in and where they are working, allowing for better team and resource management.
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import Accordion from "react-bootstrap/Accordion";
import api from "../api/api";
import { Employee } from "../api/interfaces/types";
import { Container, Row, Col, Alert } from "react-bootstrap";
import {
HourglassSplit,
Person,
PersonFill,
GeoAlt,
CheckCircle,
XCircle,
Power,
Clock,
ClockHistory,
Trash,
} from "react-bootstrap-icons";
import TimeElapsed from "./TimeElapsed";
import { useUserProfile } from "../context/UserProfileContext";
import Loader from "./Loader";
import ConfirmModal from "./ConfirmModal";
import { formatDateTime } from "../utils/dateUtils";
import LoadingButton from "./LoadingButton";
import { useSubscription } from "../context/SubscriptionContext";
import styles from "./styles/StableApp.module.css";
import { useToast } from "../context/ToastContext";
const EmployeeList: React.FC = () => {
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState<boolean>(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [employeeToDelete, setEmployeeToDelete] = useState<number | null>(null);
const [selectedSessionId, setSelectedSessionId] = useState<number | null>(
null
);
const navigate = useNavigate();
const { showToast } = useToast();
const { refreshProfile } = useUserProfile();
const { refresh: refreshSubscription } = useSubscription();
const confirmDeleteEmployee = (employeeId: number): Promise<void> => {
setEmployeeToDelete(employeeId);
setShowDeleteModal(true);
return Promise.resolve();
};
useEffect(() => {
const fetchEmployees = async () => {
try {
const response = await api.get<Employee[]>("/employee");
setEmployees(response.data);
setLoading(false);
} catch (err: any) {
console.error("Error fetching employees:", err);
setError("Error fetching employees");
setLoading(false);
}
};
fetchEmployees();
}, []);
const handleEmployee = async (id: number): Promise<void> => {
navigate(`/employees/${id}`);
};
const handleEndSession = async (): Promise<void> => {
if (selectedSessionId !== null) {
try {
await api.patch(`/livesession/end/${selectedSessionId}/`);
const updatedEmployees = employees.map((employee) =>
employee.current_session_id === selectedSessionId
? { ...employee, current_session_status: "Finished" }
: employee
);
setEmployees(updatedEmployees);
setShowModal(false);
showToast("Arbetet har avslutats", "success");
} catch (error) {
console.error("Error ending session", error);
setError("Error ending session");
showToast("Det gick inte att avsluta arbetet", "error");
}
}
};
const handleDeleteEmployee = async (): Promise<void> => {
if (!employeeToDelete) return;
try {
await api.delete(`/accounts/employee/${employeeToDelete}/delete/`);
setEmployees(employees.filter((emp) => emp.user_id !== employeeToDelete));
// ⬇️ od razu odśwież profil (dzięki temu liczba użytkowników w headerze itp. się od razu zmieni)
await refreshProfile();
await refreshSubscription();
showToast("Anställd har tagits bort", "success");
} catch (error) {
console.error("Error deleting employee", error);
setError("Fel vid borttagning av anställd");
showToast("Det gick inte att ta bort anställd", "error");
} finally {
setShowDeleteModal(false);
setEmployeeToDelete(null);
}
};
const handleShowModal = async (sessionId: number): Promise<void> => {
setSelectedSessionId(sessionId);
setShowModal(true);
};
if (loading) return <Loader />;
if (error) return <div>Error: {error}</div>;
return (
<Container className={styles.pageUnderNavbar}>
<Row className="justify-content-center">
<Col md={6} className="p-0">
{employees.length === 0 ? (
<Alert variant="info" className="text-center mt-3">
Inga anställda i teamet
</Alert>
) : (
<Accordion>
{employees.map((employee, index) => (
<Accordion.Item eventKey={String(index)} key={employee.id}>
<Accordion.Header>
{employee.current_session_status === "On_going" ? (
<PersonFill className="me-2 text-success" />
) : (
<Person className="me-2" />
)}
{employee.full_name}
</Accordion.Header>
<Accordion.Body>
<div className="d-flex align-items-center justify-content-between mb-2 small">
<div className="d-flex align-items-center">
{employee.current_session_status === "On_going" ? (
<CheckCircle className="text-success me-2" />
) : (
<XCircle className="text-danger me-2" />
)}
{employee.current_session_status === "On_going"
? "För närvarande arbetar"
: "Arbetar inte"}
</div>
</div>
{employee.current_session_status === "On_going" && (
<>
<div className="d-flex align-items-center mb-2 small">
<GeoAlt className="me-2" />
{employee.current_workplace &&
(() => {
const {
street,
street_number,
postal_code,
city,
} = employee.current_workplace;
const streetPart =
street && street_number
? `${street} ${street_number}`
: null;
const postalCityPart = `${postal_code} ${city}`;
return [streetPart, postalCityPart]
.filter(Boolean)
.join(", ");
})()}
</div>
<div className="d-flex align-items-center mb-2 small">
<Clock className="me-2" />
{formatDateTime(employee.current_session_start_time)}
</div>
<div className="d-flex align-items-center mb-2 small">
<HourglassSplit className="me-2" />
<TimeElapsed
startTime={employee.current_session_start_time}
/>
</div>
</>
)}
<div className="d-flex flex-column gap-2 mt-3">
<LoadingButton
variant="primary"
onClick={() => handleEmployee(employee.id)}
icon={ClockHistory}
title="Tidspårningar"
size={24}
label="Tidspårningar"
/>
{employee.current_session_status === "On_going" && (
<LoadingButton
variant="success"
onClick={() =>
handleShowModal(employee.current_session_id)
}
icon={Power}
title="Avsluta arbete"
size={24}
label="Avsluta arbete"
/>
)}
{employee.current_session_status !== "On_going" && (
<div className="mt-3 border-top pt-3">
<LoadingButton
variant="danger"
onClick={() =>
confirmDeleteEmployee(employee.user_id)
}
icon={Trash}
title="Radera konto"
size={24}
label="Radera konto"
/>
<div className="text-danger small mt-1 text-center">
Detta kan inte ångras
</div>
</div>
)}
</div>
</Accordion.Body>
</Accordion.Item>
))}
</Accordion>
)}
</Col>
</Row>
<ConfirmModal
show={showModal}
onHide={() => setShowModal(false)}
onConfirm={handleEndSession}
>
Bekräfta avslut av aktuellt arbete
</ConfirmModal>
<ConfirmModal
show={showDeleteModal}
onHide={() => setShowDeleteModal(false)}
onConfirm={handleDeleteEmployee}
>
<p>Är du säker på att du vill ta bort denna anställd?</p>
<p className="text-danger">
Detta kan inte ångras och alla data kopplade till användaren tas bort.
</p>
</ConfirmModal>
</Container>
);
};
export default EmployeeList;
Work session management in the Gurudo Geo application is designed to provide both employees and employers with efficient tools to monitor and manage work hours.
-
Error editing work hours on the phone.
- The error was related to passing an object as a string when the server expected only a numeric ID type. Fixed.
-
Improvement of "Next" and "Back" button styles.
- The buttons were too large, hindering proper UX. Updated style.
-
Return from editing work hours to the specific day.
- The application did not return to the previous view after editing work hours. Fixed.
-
Employer return from day view to month view.
- The error was related to an incorrect endpoint. Now correctly loads user data upon return.
-
Lack of key icon in the password change form.
- Added the ability to toggle password visibility in the password change form.
-
Information about link expiration.
- Added an alert informing about link expiration and a redirection to the password recovery function if the user wishes to reset the password.
-
Weak internet connection.
- Added a network connection alert. The alert appears when the user is out of network range and disappears when the connection is restored.
-
Loader on the daily view.
- Optimized loader functionality to load only work sessions, not the entire component.
-
Loader on buttons.
- Added a loader to buttons to inform the user to wait for a server response.
-
Title and back button in Navbar on the work session addition view.
- Added correct title and back button.
-
Modal for deleting work session.
- Added a confirmation modal for deleting a work session, increasing operational security.
-
Modal for deleting workplace.
- Added a confirmation modal for deleting a workplace and refreshing the workplace list after deletion.
-
User profile deletion.
- Added the ability for a user to delete their account with confirmation and information about the irreversibility of the operation.
-
Blocking workplace deletion with active sessions.
- Added a function to prevent deleting a workplace if someone is currently working there.
-
Refreshing state after adding work hours by the employer.
- Added automatic state refresh of work hours after addition.
-
Issue loading work sessions by new user.
- Added an alert informing about no work sessions for new users.
-
Blocking login page access for logged-in users.
- Added a function redirecting logged-in users to the home page.
-
Automatic logout after password change.
- Users are now automatically logged out and redirected to the login page after changing their password.
-
Improved alert display.
- Improved alert display style and categorized them (info, warning, danger).
-
Improved active session view.
- Standardized the active session user view.
-
Day names in the monthly view.
- Added day names to the monthly view for better clarity.
-
Standardized workplace addresses.
- Standardized the format of displaying workplace addresses.
-
Capitalization of first and last name.
- Implemented automatic capitalization of the user's first and last name.
The Gurudo Geo project was created and is maintained by Lucka Glowacz, an experienced Full Stack Developer. If you have any questions about the project, need assistance, or have suggestions for the development of the application, please contact:
- Name: Lucka Glowacz
- Role: Full Stack Developer
- Email: [email protected]
Lucka is open to any questions and is happy to help resolve any issues related to the Gurudo Geo application.
The Gurudo Geo project is released under the MIT License. Detailed information about the license is provided below:
MIT License
Copyright (c) 2024 Lucka Glowacz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Special thanks to:
- My girlfriend, Swietlana, for her support at every stage of the project.
- My two-person team — thank you, Marek.
- Everyone who contributed through manual testing and real-world usage of the application.
- Friends who inspired and motivated me to build this project.
Thank you for your invaluable help and support.
Lucka
