Timesheet System - Sprint 2 - Times Codes

Timesheet System - Time Codes and Staff Relationships

Timesheet System - Time Codes and Staff Relationships | Blog

Timesheet System - Time Codes and Staff Relationships

We finished the last post with a system that had created an initial admin, that admin could then create other users, such as more Admins, Managers or Team Members. We added a little styling, but that was never going to be the focus for me in this project. That said, the system was far from a timesheet system. Indeed, by the end of this post, we still won’t have the ability to create or submit timesheets, we’re still building out the underlying logic which will enable the end functions for this system.

Sprint 2 Goals:

  • Enhance Manager-Staff Relationships: Allow managers to oversee and manage their respective staff members.
  • Implement Staff Status Management: Enable tracking of staff status (Active/Ex-Staff) for better resource management.
  • Develop Time Code Systems: Create a robust system for managing and categorising time codes, essential for tracking work hours and activities.

Now actually, this sprint should have included some of the things we did in the last sprint, I made a mistake there and implemented features that weren’t expected outcome for the sprint, I think I’ll just gloss over that part for now…

Departments and Teams

As I was developing the project I relaised that on testing my first itteration of the staff-to-manager relationships that a very large organisation would have a nightmare keeping all the relationships in place, I think the new structure makes this a little easier to maintain as the number of staff grows.

Introducing Departments and Teams will not only streamline user management but also establish a clear hierarchical structure that can significantly improve operational efficiency.

Departments:

  • Purpose: Serve as high-level organisational units (e.g., IT, HR, Marketing).
  • Functionality:
    • Add, edit, and remove departments.
    • Assign department managers.
    • View teams within a department.

Teams:

  • Purpose: Sub-units within departments (e.g., Service Desk Team, Network Support Team).
  • Functionality:
    • Add, edit, and remove teams.
    • Assign team managers.
    • Associate teams with departments.

Database Schema Design:

  • Create new tables for Departments and Teams.
  • Update the Users table to include foreign keys for Teams and Departments.
  • Ensure referential integrity with proper constraints.

Starting with Departments:


php artisan make:migration create_departments_table


class CreateDepartmentsTable extends Migration
{
    public function up()
    {
        Schema::create('departments', function (Blueprint $table) {
            $table->id();
            $table->string('name')->unique();
            $table->foreignId('manager_id')->nullable()->constrained('users')->onDelete('set null');
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('departments');
    }
}

Teams are much the same:


php artisan make:migration create_teams_table


public function up()
    {
        Schema::create('teams', function (Blueprint $table) {
            $table->id();
            $table->string('name')->unique();
            $table->foreignId('department_id')->constrained()->onDelete('cascade');
            $table->foreignId('manager_id')->nullable()->constrained('users')->onDelete('set null');
            $table->timestamps();
        });
    }

    // and associated down()

We need to work on the User table to build out these connections to Teams and Departments:


php artisan make:migration add_teams_and_departments_to_users_table --table=users


public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->foreignId('team_id')->nullable()->constrained()->onDelete('set null');
            $table->foreignId('department_id')->nullable()->constrained()->onDelete('set null');
            $table->boolean('is_active')->default(true);
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropForeign(['team_id']);
            $table->dropColumn('team_id');
            $table->dropForeign(['department_id']);
            $table->dropColumn('department_id');
            $table->dropColumn('is_active');
        });
    }

When we’re ready, we can run the new migrations:


php artisan migrate

Model Relationships:

  • Define Eloquent relationships between Departments, Teams, and Users.
  • Implement recursive relationships if necessary (e.g., Managers can belong to multiple teams/departments).

Eloquent Relationships in Laravel are a way to define and work with connections between database tables in an object-oriented way.

We need a new Model: ./app/Models/Department.php:


<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Department extends Model
{
    use HasFactory;

    protected $fillable = ['name', 'manager_id'];

    /**
     * Get the manager of the department.
     */
    public function manager()
    {
        return $this->belongsTo(User::class, 'manager_id');
    }

    /**
     * Get the teams for the department.
     */
    public function teams()
    {
        return $this->hasMany(Team::class);
    }
}

We will also need to create a Team model:


<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Team extends Model
{
    use HasFactory;

    protected $fillable = ['name', 'department_id', 'manager_id'];

    /**
     * Get the department that owns the team.
     */
    public function department()
    {
        return $this->belongsTo(Department::class);
    }

    /**
     * Get the manager of the team.
     */
    public function manager()
    {
        return $this->belongsTo(User::class, 'manager_id');
    }

    /**
     * Get the users for the team.
     */
    public function users()
    {
        return $this->hasMany(User::class);
    }
}

As with the database enhancements, we also need to change our User Model ./app/Models/User.php:


    // The User belongs to THIS Team (One to One)
    public function team() 
    {
        return $this->belongsTo(Team::class);
    }

    // The User belongs to THIS Department (One to One)
    public function department()
    {
        return $this->belongsTo(Department::class);
    }

    // This User has MANY Staff (One to Many)
    public function managedTeams()
    {
        return $this->hasMany(Team::class, 'manager_id');
    }

    // This User has ONE Department (One to One)
    public function managedDepartment()
    {
        return $this->hasOne(Department::class, 'manager_id');
    }

Controller Enhancements:

  • Create Controllers for Departments and Teams.
  • Update UserController to handle team and department assignments.
  • Implement separate management for Admins.

php artisan make:controller Admin/DepartmentController --resource
php artisan make:controller Admin/TeamController --resource

Controllers in Laravel act as an interface between routes and your application logic - they receive HTTP requests, process them (often using models and services), and return appropriate responses (usually views or JSON data). For us, we are expecting Views.

Blade Views Development:

  • Develop views for managing Departments and Teams.

We’re going to need a bunch of new Blade files. Or at least, I am using Blade files in my version of this app, you might be implementing a more interesting frontend for example. For both Departments & Teams (and probably for the Time Codes and Timesheets), we’re going to need:

  • index
  • create
  • edit
  • show

We can start with filler files for now, just a landing page so that all of the routes work.


<x-app-layout>
    <div class="container mt-5">
        <h1>Teams - JUST A FILLER FOR NOW</h1>
        <p>Welcome to your Teams view</p>
    </div>
</x-app-layout>

  • Modify the existing navigation to include new sections.

I have updated my Navigation component to include all of the expected Navigation elements at this stage of the build (I have added things that we haven’t built yet, but they just lead to placeholder blade files at this stage):

The Navigation Menu (probably finished article)

<nav x-data="{ open: false }" class="bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700">
    <!-- Primary Navigation Menu -->
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="flex justify-between h-16">
            <div class="flex">
                <!-- Logo -->
                <div class="shrink-0 flex items-center">
                    <a href="{{ route('dashboard') }}">
                        <x-application-logo class="block h-9 w-auto fill-current text-gray-800 dark:text-gray-200" />
                    </a>
                </div>

                <!-- Navigation Links -->
                <div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
                    <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
                        {{ __('Dashboard') }}
                    </x-nav-link>

                    @if(Auth::check() && Auth::user()->role === 'admin')
                        <x-nav-link :href="route('admin.users.index')" :active="request()->routeIs('admin.users.*')">
                            {{ __('Users') }}
                        </x-nav-link>

                        <x-nav-link :href="route('admin.departments.index')" :active="request()->routeIs('admin.departments.*')">
                            {{ __('Departments') }}
                        </x-nav-link>

                        <x-nav-link :href="route('admin.teams.index')" :active="request()->routeIs('admin.teams.*')">
                            {{ __('Teams') }}
                        </x-nav-link>

                        <x-nav-link :href="route('admin.timecodes.index')" :active="request()->routeIs('admin.timecodes.*')">
                            {{ __('Time Codes') }}
                        </x-nav-link>

                        <x-nav-link :href="route('admin.timesheets.index')" :active="request()->routeIs('admin.timesheets.*')">
                            {{ __('Timesheets') }}
                        </x-nav-link>
                    @endif
                </div>
            </div>

            <!-- Settings Dropdown -->
            <div class="hidden sm:flex sm:items-center sm:ms-6">
                <x-dropdown align="right" width="48">
                    <x-slot name="trigger">
                        <button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none transition ease-in-out duration-150">
                            <div>{{ Auth::user()->name }}</div>

                            <div class="ms-1">
                                <svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
                                    <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
                                </svg>
                            </div>
                        </button>
                    </x-slot>

                    <x-slot name="content">
                        <x-dropdown-link :href="route('profile.edit')">
                            {{ __('Profile') }}
                        </x-dropdown-link>

                        @if(Auth::check() && Auth::user()->role === 'admin')
                            <x-dropdown-link :href="route('admin.users.index')">
                                {{ __('Users') }}
                            </x-dropdown-link>

                            <x-dropdown-link :href="route('admin.departments.index')">
                                {{ __('Departments') }}
                            </x-dropdown-link>

                            <x-dropdown-link :href="route('admin.teams.index')">
                                {{ __('Teams') }}
                            </x-dropdown-link>
                        @endif

                        <!-- Authentication -->
                        <form method="POST" action="{{ route('logout') }}">
                            @csrf

                            <x-dropdown-link :href="route('logout')"
                                    onclick="event.preventDefault();
                                                this.closest('form').submit();">
                                {{ __('Log Out') }}
                            </x-dropdown-link>
                        </form>
                    </x-slot>
                </x-dropdown>
            </div>

            <!-- Hamburger -->
            <div class="-me-2 flex items-center sm:hidden">
                <button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-900 focus:outline-none transition duration-150 ease-in-out">
                    <svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
                        <path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
                        <path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                    </svg>
                </button>
            </div>
        </div>
    </div>

    <!-- Responsive Navigation Menu -->
    <div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
        <div class="pt-2 pb-3 space-y-1">
            <x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
                {{ __('Dashboard') }}
            </x-responsive-nav-link>

            @if(Auth::check() && Auth::user()->role === 'admin')
                <x-responsive-nav-link :href="route('admin.users.index')" :active="request()->routeIs('admin.users.*')">
                    {{ __('Users') }}
                </x-responsive-nav-link>

                <x-responsive-nav-link :href="route('admin.departments.index')" :active="request()->routeIs('admin.departments.*')">
                    {{ __('Departments') }}
                </x-responsive-nav-link>

                <x-responsive-nav-link :href="route('admin.teams.index')" :active="request()->routeIs('admin.teams.*')">
                    {{ __('Teams') }}
                </x-responsive-nav-link>

                <x-responsive-nav-link :href="route('admin.timecodes.index')" :active="request()->routeIs('admin.timecodes.*')">
                    {{ __('Time Codes') }}
                </x-responsive-nav-link>

                <x-responsive-nav-link :href="route('admin.timesheets.index')" :active="request()->routeIs('admin.timesheets.*')">
                    {{ __('Timesheets') }}
                </x-responsive-nav-link>
            @endif
        </div>

        <!-- Responsive Settings Options -->
        <div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-600">
            <div class="px-4">
                <div class="font-medium text-base text-gray-800 dark:text-gray-200">{{ Auth::user()->name }}</div>
                <div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
            </div>

            <div class="mt-3 space-y-1">
                <x-responsive-nav-link :href="route('profile.edit')">
                    {{ __('Profile') }}
                </x-responsive-nav-link>

                @if(Auth::check() && Auth::user()->role === 'admin')
                    <x-responsive-nav-link :href="route('admin.users.index')">
                        {{ __('Users') }}
                    </x-responsive-nav-link>

                    <x-responsive-nav-link :href="route('admin.departments.index')">
                        {{ __('Departments') }}
                    </x-responsive-nav-link>

                    <x-responsive-nav-link :href="route('admin.teams.index')">
                        {{ __('Teams') }}
                    </x-responsive-nav-link>
                @endif

                <!-- Authentication -->
                <form method="POST" action="{{ route('logout') }}">
                    @csrf

                    <x-responsive-nav-link :href="route('logout')"
                            onclick="event.preventDefault();
                                        this.closest('form').submit();">
                        {{ __('Log Out') }}
                    </x-responsive-nav-link>
                </form>
            </div>
        </div>
    </div>
</nav>


Sprint 2 - updated

I committed a cardinal sin. I added things that were not planned in this sprint. Features/functionality that I hadn’t realised I needed until I started building out the project. As I state earlier in this post, I relaised that I was building what I think would be an organisational nightmare. So I added some new elements to this build and set about defining relationships by Team and Department before Manager.

Sprint 2 - in progress

Time Codes

We will need time codes before we can build out a timesheet system, so our focus will shift in this direction.

Begin with creating new files (if you haven’t already created them):


php artisan make:controller Admin/TimeCodeController --resource
php artisan make:model TimeCode
php artisan make:migration create_time_codes_table

We can look at each of these files.

./app/Http/Controllers/Admin/TimeCodeController.php

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\TimeCode;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class TimeCodeController extends Controller
{
    /**
     * Display a listing of the time codes.
     */
    public function index()
    {
        $timeCodes = TimeCode::paginate(10);
        return view('admin.timecodes.index', compact('timeCodes'));
    }

    /**
     * Show the form for creating a new time code.
     */
    public function create()
    {
        return view('admin.timecodes.create');
    }

    /**
     * Store a newly created time code in storage.
     */
    public function store(Request $request)
    {
        // Validate input
        $request->validate([
            'code' => 'required|string|max:50|unique:time_codes,code',
            'description' => 'required|string|max:255',
        ]);

        // Create time code
        $timeCode = TimeCode::create([
            'code' => $request->code,
            'description' => $request->description,
        ]);

        Log::info("Time Code created: ID {$timeCode->id} ({$timeCode->code}) by Admin ID " . auth()->id());

        return redirect()->route('admin.timecodes.index')->with('success', 'Time Code created successfully.');
    }

    /**
     * Display the specified time code.
     */
    public function show($id)
    {
        $timeCode = TimeCode::findOrFail($id);
        return view('admin.timecodes.show', compact('timeCode'));
    }

    /**
     * Show the form for editing the specified time code.
     */
    public function edit($id)
    {
        $timeCode = TimeCode::findOrFail($id);
        return view('admin.timecodes.edit', compact('timeCode'));
    }

    /**
     * Update the specified time code in storage.
     */
    public function update(Request $request, $id)
    {
        $timeCode = TimeCode::findOrFail($id);

        // Validate input
        $request->validate([
            'code' => 'required|string|max:50|unique:time_codes,code,' . $timeCode->id,
            'description' => 'required|string|max:255',
        ]);

        // Update time code
        $timeCode->update([
            'code' => $request->code,
            'description' => $request->description,
        ]);

        Log::info("Time Code updated: ID {$timeCode->id} ({$timeCode->code}) by Admin ID " . auth()->id());

        return redirect()->route('admin.timecodes.index')->with('success', 'Time Code updated successfully.');
    }

    /**
     * Remove the specified time code from storage.
     */
    public function destroy($id)
    {
        $timeCode = TimeCode::findOrFail($id);

        // Check if time code is used in any timesheets
        if ($timeCode->timesheets()->count() > 0) {
            return redirect()->route('admin.timecodes.index')->withErrors(['error' => 'Cannot delete time code used in timesheets.']);
        }

        $timeCode->delete();

        Log::info("Time Code deleted: ID {$timeCode->id} ({$timeCode->code}) by Admin ID " . auth()->id());

        return redirect()->route('admin.timecodes.index')->with('success', 'Time Code deleted successfully.');
    }
}

./app/Models/TimeCode.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class TimeCode extends Model
{
    use HasFactory;

    protected $fillable = ['code', 'description'];

    /**
     * Get the timesheets associated with the time code.
     */
    public function timesheets()
    {
        return $this->hasMany(Timesheet::class);
    }
}

./database/migrations/xxxx_xx_xx_create_time_codes_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateTimeCodesTable extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('time_codes', function (Blueprint $table) {
            $table->id();
            $table->string('code')->unique();
            $table->string('description');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('time_codes');
    }
}

With the addition of my Blade views, I am now able to see an index of available Time Codes, as well as Create, Read (view), Update (edit) and Delete time codes from the system (CRUD):

Managing Time Codes

Timesheets

So at this stage we have some really important building blocks in place. We have an initial administrator, that first admin can make more admins as required and any admins can also create users, departments, teams and time codes. I have added some additional functionality to my version of the Timesheet System to do things like searching and filtering users as this is the area I think we’re going to find the largest number of objects in most organisations. Of course, not all organisations are large, but then smaller organisations probably aren’t going to get hung up on vast options for time codes as well. I added some functionality around managing admins as well, this is pretty much the same as how we manage users, with some exceptions, mostly around the fact that an admin probably doesn’t need to be allocated to a team or department, though perhaps that is a feature that could be added later so that admins are responsible for users in their own departments for example, but that is definitely not MVP stuff.

So to be honest, the only thing I haven’t built yet is the ability to create a timesheet and all the logic that will support timesheet creation, verification and approval, that’s something we can look at in the next blog post.