Timesheet System - Sprint 3

Timesheet System - core functionality

Timesheet System - core functionality | Blog

Timesheet System - core functionality

import YouTube from ’../../components/YouTube.jsx’;

The Timesheet System is starting to come together. We’ve spent the last couple of weeks building some related logic but this week we finally look at adding Timesheet Functionality to our Timesheet System, imagine that.

Sprint 3

Sprint 3 focuses on implementing the core functionalities of the Timesheet feature within the Timesheet System. This sprint aims to provide users with the ability to create, manage, and submit timesheets efficiently. The primary objectives include establishing the necessary models and relationships, designing intuitive interfaces for weekly timesheet entries, managing line items, selecting time codes, adding comments, and enabling draft saving and submission for approval.

Objectives

  • S36: Create Timesheet Models and Relationships
  • S37: Weekly Timesheet Interface
  • S38: Line Item Management for Timesheets
  • S39: Time Code Selection and Hours Entry
  • S40: Comments Field for Line Items
  • S41: Save Draft Functionality
  • S42: Submit for Approval Functionality

As a reminder, we finished the system last week, looking like this:


Create Timesheet Models and Relationships

Develop the Timesheet model along with its relationships to other models such as User, LineItem, and TimeCode.

We already have a Timesheet Model, it was basically just boilerplate, so I am going to delete it and then generate new Models and associated Database Migrations:


php artisan make:model Timesheet -m
php artisan make:model LineItem -m

Both Models are created with boilerplate, so let’s make changes to Timesheet:


// Timesheet Model

<?php

namespace App\Models;

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

class Timesheet extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'week_start_date',
        'status', // e.g., draft, submitted, approved, rejected
    ];

    /**
     * Get the user that owns the timesheet.
     */
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    /**
     * Get the line items for the timesheet.
     */
    public function lineItems()
    {
        return $this->hasMany(LineItem::class);
    }
}

We’re describing the connections between components; each Timesheet belongs to One User, and One Timesheet can have many LineItems,

What is a Line Item?

A LineItem is kind of how we describe the different elements that make up a timesheet. We can allow the administrator to define how complex they want this to be, it could be as simple as recording 7.5 hours per day at the standard day time rate, or it might be a way for the administrator to track contributions to multiple projects, an example could look like this:

Timesheet Example showing line items

For me, one of the characteristics of this project is not to build every possible scenario, but to build flexibility for the timesheet administrator(s) to define the level of complexity they require, obviously within the limits of my imagination (hence why soliciting short-term feedback is a key to long-term success).


// app/Models/LineItem.php
<?php

namespace App\Models;

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

class LineItem extends Model
{
    use HasFactory;

    protected $fillable = [
        'timesheet_id',
        'time_code_id',
        'hours',
        'comments',
    ];

    /**
     * Get the timesheet that owns the line item.
     */
    public function timesheet()
    {
        return $this->belongsTo(Timesheet::class);
    }

    /**
     * Get the time code associated with the line item.
     */
    public function timeCode()
    {
        return $this->belongsTo(TimeCode::class);
    }
}

We already have a TimeCode model but we need to make some changes:


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

We then shift focus to database changes, that command php artisan make:model SOMETHING -m creates the Model and a related Migration file for us, so we should find something in out ./src/database/migrations/ with a name relating to our new models, if not, then you can use Artisan to create any missing files:


php artisan make:migration create_timesheets_table

The migration files describe what a table should look like and how it connects to other tables in the application, for the Timesheets table, we’re going to need to track some items early on:


// bring the new table up 
{
  Schema::create('timesheets', function (Blueprint $table) {
    $table-id();
    $table->foreignId('user_id')->constrained()-onDelete('cascade');
    $table->date('week_start_date');
    $table->enum('status', ['draft', 'submitted', 'approved', 'rejected'])->default('draft');
    $table->timestamps();
  });
}

// don't forget your drop method for unwanted table

We can take a moment to understand this file structure:


$table->id();
// Creates an auto-incrementing primary key column named 'id'

$table->foreignId('user_id')->constrained()->onDelete('cascade');
// Creates a foreign key column 'user_id' that references the id column in the users table
// The onDelete('cascade') means if a user is deleted, all their timesheets will also be deleted
// the timesheet admin might not want this, legacy data may be important, so if I remember I will revisit this later

$table->date('week_start_date');
// Creates a date column to store the starting date of the timesheet week

$table->enum('status', ['draft', 'submitted', 'approved', 'rejected'])->default('draft');
// Creates an enum column named 'status' that can only contain these four specific values
// Sets 'draft' as the default status when a new timesheet is created

$table->timestamps();
// Adds two timestamp columns: 'created_at' and 'updated_at'
// These automatically track when records are created and modified

We will follow the same structure for our LineItems migration file:


    Schema::create('line_items', function (Blueprint $table) {
        $table->id();
        $table->foreignId('timesheet_id')->constrained()->onDelete('cascade');
        $table->foreignId('time_code_id')->constrained()->onDelete('restrict');
        $table->decimal('hours', 5, 2);
        $table->text('comments')->nullable();
        $table->timestamps();
    });

There are a couple of new items there, but I think the one we can pick apart is the onDelete('restrict'):


$table->foreignId('time_code_id')  
// Creates a column named 'time_code_id' as a foreign key
    ->constrained()                
    // Creates a foreign key constraint referencing the 'time_codes' table
    ->onDelete('restrict');        
    // Prevents deletion of the referenced time_code if it's being used

When ready, run a migration: php artisan migrate which should create those new tables for us.


Weekly Timesheet Interface

Design a user-friendly interface that allows users to view and manage their weekly timesheets. If I have any strong points in this game, it definitely isn’t Design. So we will take this objective with a pinch of salt and your designs may look somewhat better.

Timesheets index

<!-- resources/views/timesheets/index.blade.php -->
@extends('layouts.app')

@section('content')
<div class="container mx-auto px-4 py-6">
    <x-card>
        <div class="flex justify-between items-center mb-4">
            <h2 class="text-xl font-semibold">Weekly Timesheets</h2>
            <a href="{{ route('timesheets.create') }}" class="bg-primary hover:bg-primary-dark text-white font-bold py-2 px-4 rounded">
                Create Timesheet
            </a>
        </div>

        @if($timesheets->isEmpty())
            <p>No timesheets found. Click "Create Timesheet" to get started.</p>
        @else
            <table class="min-w-full bg-white dark:bg-gray-800">
                <thead>
                    <tr>
                        <th class="py-2">Week Starting</th>
                        <th class="py-2">Status</th>
                        <th class="py-2">Actions</th>
                    </tr>
                </thead>
                <tbody>
                    @foreach($timesheets as $timesheet)
                        <tr class="text-center">
                            <td class="py-2">{{ $timesheet->week_start_date->format('d M Y') }}</td>
                            <td class="py-2 capitalize">{{ $timesheet->status }}</td>
                            <td class="py-2">
                                <a href="{{ route('timesheets.edit', $timesheet->id) }}" class="text-blue-500 hover:underline">Edit</a>
                                @if($timesheet->status === 'submitted')
                                    | <a href="#" class="text-green-500 hover:underline">Approve</a>
                                    | <a href="#" class="text-red-500 hover:underline">Reject</a>
                                @endif
                            </td>
                        </tr>
                    @endforeach
                </tbody>
            </table>
        @endif
    </x-card>
</div>
@endsection

We already have a Timesheet Controller from previous work, but we need to update it. ./app/http/Controllers/TimesheetController.php:


// users timesheet index
public function index() 
{
    $timesheets = Timesheet::where('user_id', Auth::id())
    ->orderBy('week_start_date', 'desc')
    ->get();

    return view('timesheets.index', compact('timesheets'));
}


Line Item Management for Timesheets

Enable users to add, edit, and remove line items within their timesheets.

Blade: Create a timesheet

<!-- resources/views/timesheets/create.blade.php -->
@extends('layouts.app')

@section('content')
<div class="container mx-auto px-4 py-6">
    <x-card>
        <h2 class="text-xl font-semibold mb-4">Create Timesheet</h2>

        <form method="POST" action="{{ route('timesheets.store') }}" x-data="timesheetForm()" x-init="init()">
            @csrf

            <!-- Week Starting Date -->
            <div class="mb-4">
                <x-label for="week_start_date" :value="__('Week Starting Date')" />
                <x-input id="week_start_date" type="date" name="week_start_date" required class="mt-1 block w-full" />
            </div>

            <!-- Line Items -->
            <div class="mb-4">
                <h3 class="text-lg font-semibold mb-2">Line Items</h3>

                <template x-for="(lineItem, index) in lineItems" :key="index">
                    <div class="flex items-center mb-2">
                        <!-- Time Code Selection -->
                        <select :name="'line_items[' + index + '][time_code_id]'" x-model="lineItem.time_code_id" class="border border-gray-300 dark:border-gray-700 rounded-md p-2 mr-2">
                            <option value="">-- Select Time Code --</option>
                            @foreach($timeCodes as $timeCode)
                                <option value="{{ $timeCode->id }}">{{ $timeCode->code }} - {{ $timeCode->description }}</option>
                            @endforeach
                        </select>

                        <!-- Hours Entry -->
                        <input type="number" step="0.1" min="0" :name="'line_items[' + index + '][hours]'" x-model="lineItem.hours" placeholder="Hours" class="border border-gray-300 dark:border-gray-700 rounded-md p-2 mr-2 w-24" />

                        <!-- Comments Field -->
                        <input type="text" :name="'line_items[' + index + '][comments]'" x-model="lineItem.comments" placeholder="Comments" class="border border-gray-300 dark:border-gray-700 rounded-md p-2 mr-2 flex-1" />

                        <!-- Remove Line Item Button -->
                        <button type="button" @click="removeLineItem(index)" class="text-red-500 hover:text-red-700">Remove</button>
                    </div>
                </template>

                <!-- Add Line Item Button -->
                <button type="button" @click="addLineItem()" class="bg-secondary hover:bg-secondary-dark text-white font-bold py-2 px-4 rounded">
                    Add Line Item
                </button>
            </div>

            <!-- Submit Button -->
            <div class="flex items-center justify-end mt-4">
                <x-button type="primary">
                    Save Draft
                </x-button>
            </div>
        </form>
    </x-card>
</div>

<script>
    function timesheetForm() {
        return {
            lineItems: [],

            init() {
                // Initialize with one empty line item
                this.addLineItem();
            },

            addLineItem() {
                this.lineItems.push({
                    time_code_id: '',
                    hours: '',
                    comments: '',
                });
            },

            removeLineItem(index) {
                this.lineItems.splice(index, 1);
            }
        }
    }
</script>
@endsection

Moving back to the controller, we can add more functionality:


// app/Http/Controllers/TimesheetController.php
<?php

namespace App\Http\Controllers;

use App\Models\Timesheet;
use App\Models\LineItem;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;

class TimesheetController extends Controller
{
    // ... existing methods ...

    // get the timesheet create view
    public function create()
    {
        $timeCodes = \App\Models\TimeCode::all();
        return view('timesheets.create', compact('timeCodes'));
    }

    // store the timesheet
    public function store(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'week_start_date' => 'required|date',
            'line_items.*.time_code_id' => 'required|exists:time_codes,id',
            'line_items.*.hours' => 'required|numeric|min:0',
            'line_items.*.comments' => 'nullable|string',
        ]);

        if ($validator->fails()) {
            return redirect()->back()->withErrors($validator)->withInput();
        }

        // Create the timesheet
        $timesheet = Timesheet::create([
            'user_id' => Auth::id(),
            'week_start_date' => $request->week_start_date,
            'status' => 'draft',
        ]);

        // Create line items
        foreach ($request->line_items as $item) {
            $timesheet->lineItems()->create([
                'time_code_id' => $item['time_code_id'],
                'hours' => $item['hours'],
                'comments' => $item['comments'] ?? null,
            ]);
        }

        return redirect()->route('timesheets.index')->with('success', 'Timesheet saved as draft.');
    }

    // show the form for editing timesheets
    public function edit($id)
    {
        $timesheet = Timesheet::with('lineItems')->findOrFail($id);

        // Ensure the authenticated user owns the timesheet
        if ($timesheet->user_id !== Auth::id()) {
            abort(403, 'Unauthorized action.');
        }

        $timeCodes = \App\Models\TimeCode::all();
        return view('timesheets.edit', compact('timesheet', 'timeCodes'));
    }

    // update a specified timesheet
    public function update(Request $request, $id)
    {
        $timesheet = Timesheet::findOrFail($id);

        // Ensure the authenticated user owns the timesheet
        if ($timesheet->user_id !== Auth::id()) {
            abort(403, 'Unauthorized action.');
        }

        $validator = Validator::make($request->all(), [
            'week_start_date' => 'required|date',
            'line_items.*.time_code_id' => 'required|exists:time_codes,id',
            'line_items.*.hours' => 'required|numeric|min:0',
            'line_items.*.comments' => 'nullable|string',
        ]);

        if ($validator->fails()) {
            return redirect()->back()->withErrors($validator)->withInput();
        }

        // Update timesheet details
        $timesheet->update([
            'week_start_date' => $request->week_start_date,
            'status' => $request->has('submit') ? 'submitted' : 'draft',
        ]);

        // Sync line items
        $timesheet->lineItems()->delete();
        foreach ($request->line_items as $item) {
            $timesheet->lineItems()->create([
                'time_code_id' => $item['time_code_id'],
                'hours' => $item['hours'],
                'comments' => $item['comments'] ?? null,
            ]);
        }

        return redirect()->route('timesheets.index')->with('success', 'Timesheet updated successfully.');
    }

    // Implement other methods like destroy, approve, reject  etc
}


Time Code Selection and Hours Entry

Allow users to select appropriate time codes for each line item and enter the corresponding hours worked.

We kind of covered this already with the changes in the TimesheetController:


$validator = Validator::make($request->all(), [
    // exists
    'line_items.*.time_code_id' => 'required|exists:time_codes,id',
    'line_items.*.hours' => 'required|numeric|min:0',
]);


Comments Field for Line Items

Provide a field for users to add comments or notes to individual line items for additional context.

TimesheetController:


$validator = Validator::make($request->all(), [
    // exists
    'line_items.*.comments' => 'nullable|string',
]);


Save Draft Functionality

Implement functionality that allows users to save their timesheets as drafts, enabling them to complete entries at a later time.


$timesheet->update([
      'week_start_date' => $request->week_start_date,
      'status' => $request->has('submit') ? 'submitted' : 'draft',
  ]);


Submit for Approval Functionality

Allow users to submit completed timesheets for managerial approval, triggering notification workflows.


<!-- Submit for Approval Button -->
<button type="submit" name="submit" value="1" class="bg-primary hover:bg-primary-dark text-white font-bold py-2 px-4 rounded">
    Submit for Approval
</button>


// TimesheetController@update
$timesheet->update([
    'week_start_date' => $request->week_start_date,
    'status' => $request->has('submit') ? 'submitted' : 'draft',
]);

Approval Process

Routes and Controller methods to handle approvals:


// TimesheetController.php

/**
 * Approve the specified timesheet.
 */
public function approve($id)
{
    $timesheet = Timesheet::findOrFail($id);

    // Ensure the user has the correct role
    if (!in_array(Auth::user()->role, ['manager', 'admin'])) {
        abort(403, 'Unauthorized action.');
    }

    $timesheet->update(['status' => 'approved']);

    // Notify the user (implementation in Notification section)
    // Notification::send($timesheet->user, new TimesheetApproved($timesheet));

    return redirect()->back()->with('success', 'Timesheet approved successfully.');
}

/**
 * Reject the specified timesheet.
 */
public function reject(Request $request, $id)
{
    $timesheet = Timesheet::findOrFail($id);

    // Ensure the user has the correct role
    if (!in_array(Auth::user()->role, ['manager', 'admin'])) {
        abort(403, 'Unauthorized action.');
    }

    $timesheet->update(['status' => 'rejected']);

    // Notify the user (implementation in Notification section)
    // Notification::send($timesheet->user, new TimesheetRejected($timesheet, $request->reason));

    return redirect()->back()->with('success', 'Timesheet rejected successfully.');
}


// routes/web.php

Route::middleware(['auth', 'checkRole:manager,admin'])->group(function () {
    Route::post('timesheets/{timesheet}/approve', [TimesheetController::class, 'approve'])->name('timesheets.approve');
    Route::post('timesheets/{timesheet}/reject', [TimesheetController::class, 'reject'])->name('timesheets.reject');
});


Notification System

Integrate Laravel’s notification system to inform users about the status of their timesheet submissions:


php artisan make:notification TimesheetApproved
php artisan make:notification TimesheetRejected

Timesheet Approved Notification

// app/Notifications/TimesheetApproved.php
<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use App\Models\Timesheet;

class TimesheetApproved extends Notification
{
    use Queueable;

    protected $timesheet;

    /**
     * Create a new notification instance.
     */
    public function __construct(Timesheet $timesheet)
    {
        $this->timesheet = $timesheet;
    }

    /**
     * Get the notification's delivery channels.
     */
    public function via($notifiable)
    {
        return ['mail'];
    }

    /**
     * Get the mail representation of the notification.
     */
    public function toMail($notifiable)
    {
        return (new MailMessage)
                    ->subject('Timesheet Approved')
                    ->greeting('Hello ' . $notifiable->name . ',')
                    ->line('Your timesheet for the week starting ' . $this->timesheet->week_start_date->format('d M Y') . ' has been approved.')
                    ->action('View Timesheet', route('timesheets.show', $this->timesheet->id))
                    ->line('Thank you for your hard work!');
    }
}

Timesheet Rejected Notifications

// app/Notifications/TimesheetRejected.php
<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use App\Models\Timesheet;

class TimesheetRejected extends Notification
{
    use Queueable;

    protected $timesheet;
    protected $reason;

    /**
     * Create a new notification instance.
     */
    public function __construct(Timesheet $timesheet, $reason)
    {
        $this->timesheet = $timesheet;
        $this->reason = $reason;
    }

    /**
     * Get the notification's delivery channels.
     */
    public function via($notifiable)
    {
        return ['mail'];
    }

    /**
     * Get the mail representation of the notification.
     */
    public function toMail($notifiable)
    {
        return (new MailMessage)
                    ->subject('Timesheet Rejected')
                    ->greeting('Hello ' . $notifiable->name . ',')
                    ->line('Your timesheet for the week starting ' . $this->timesheet->week_start_date->format('d M Y') . ' has been rejected.')
                    ->line('Reason: ' . $this->reason)
                    ->action('View Timesheet', route('timesheets.show', $this->timesheet->id))
                    ->line('Please make the necessary corrections and resubmit.');
    }
}

Sprint 3 - timesheet functionality

What does it look like ????