-
-{
- "errors": {
- "email": [
- "The email field is required."
- ],
- "password": [
- "The password field is required."
- ]
- }
-}
-
-
-
-
Received response:
-
-
-
-
-
Request failed with error:
-
-
-Tip: Check that you're properly connected to the network.
-If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
-You can check the Dev Tools console for debugging information.
-
-
-
-
Refresh access token
-
-
-requires authentication
-
-
-
Exchange a valid refresh token for a new access token and refresh token pair.
-
-Tip: Check that you're properly connected to the network.
-If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
-You can check the Dev Tools console for debugging information.
-
-
-
-
Logout current session
-
-
-requires authentication
-
-
-
Invalidate a refresh token and end the active authenticated session.
-
-Tip: Check that you're properly connected to the network.
-If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
-You can check the Dev Tools console for debugging information.
-
-
-
-
-
+
diff --git a/backend/routes/api.php b/backend/routes/api.php
index f56cfe2f..96580fb2 100644
--- a/backend/routes/api.php
+++ b/backend/routes/api.php
@@ -1,6 +1,7 @@
group(function () {
'role' => $request->user()->role,
]);
});
+
+ // Team Members
+ Route::apiResource('team-members', TeamMemberController::class);
});
diff --git a/backend/tests/Feature/TeamMember/TeamMemberTest.php b/backend/tests/Feature/TeamMember/TeamMemberTest.php
new file mode 100644
index 00000000..457479f7
--- /dev/null
+++ b/backend/tests/Feature/TeamMember/TeamMemberTest.php
@@ -0,0 +1,238 @@
+create([
+ 'email' => 'manager@example.com',
+ 'password' => bcrypt('password123'),
+ 'role' => 'manager',
+ 'active' => true,
+ ]);
+
+ $response = $this->postJson('/api/auth/login', [
+ 'email' => 'manager@example.com',
+ 'password' => 'password123',
+ ]);
+
+ return $response->json('access_token');
+ }
+
+ // 2.1.9 API test: POST /api/team-members creates member
+ public function test_post_team_members_creates_member()
+ {
+ $token = $this->loginAsManager();
+ $role = Role::factory()->create(['name' => 'Backend Developer']);
+
+ $response = $this->withHeader('Authorization', "Bearer {$token}")
+ ->postJson('/api/team-members', [
+ 'name' => 'John Doe',
+ 'role_id' => $role->id,
+ 'hourly_rate' => 150.00,
+ 'active' => true,
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJson([
+ 'name' => 'John Doe',
+ 'role_id' => $role->id,
+ 'hourly_rate' => '150.00',
+ 'active' => true,
+ ]);
+
+ $this->assertDatabaseHas('team_members', [
+ 'name' => 'John Doe',
+ 'role_id' => $role->id,
+ 'active' => true,
+ ]);
+ }
+
+ // 2.1.10 API test: Validate hourly_rate > 0
+ public function test_validate_hourly_rate_must_be_greater_than_zero()
+ {
+ $token = $this->loginAsManager();
+ $role = Role::factory()->create(['name' => 'Backend Developer']);
+
+ // Test with zero
+ $response = $this->withHeader('Authorization', "Bearer {$token}")
+ ->postJson('/api/team-members', [
+ 'name' => 'John Doe',
+ 'role_id' => $role->id,
+ 'hourly_rate' => 0,
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors(['hourly_rate']);
+
+ // Test with negative
+ $response = $this->withHeader('Authorization', "Bearer {$token}")
+ ->postJson('/api/team-members', [
+ 'name' => 'John Doe',
+ 'role_id' => $role->id,
+ 'hourly_rate' => -50,
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors(['hourly_rate']);
+ $response->assertJsonFragment([
+ 'hourly_rate' => ['Hourly rate must be greater than 0'],
+ ]);
+ }
+
+ // 2.1.11 API test: Validate required fields
+ public function test_validate_required_fields()
+ {
+ $token = $this->loginAsManager();
+
+ $response = $this->withHeader('Authorization', "Bearer {$token}")
+ ->postJson('/api/team-members', []);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors(['name', 'role_id', 'hourly_rate']);
+ }
+
+ // 2.1.12 API test: GET /api/team-members returns all members
+ public function test_get_team_members_returns_all_members()
+ {
+ $token = $this->loginAsManager();
+ $role = Role::factory()->create();
+
+ // Create active and inactive team members
+ TeamMember::factory()->count(2)->create(['role_id' => $role->id, 'active' => true]);
+ TeamMember::factory()->create(['role_id' => $role->id, 'active' => false]);
+
+ $response = $this->withHeader('Authorization', "Bearer {$token}")
+ ->getJson('/api/team-members');
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(3);
+ }
+
+ // 2.1.13 API test: Filter by active status
+ public function test_filter_by_active_status()
+ {
+ $token = $this->loginAsManager();
+ $role = Role::factory()->create();
+
+ // Create active and inactive team members
+ TeamMember::factory()->count(2)->create(['role_id' => $role->id, 'active' => true]);
+ TeamMember::factory()->create(['role_id' => $role->id, 'active' => false]);
+
+ // Get only active
+ $response = $this->withHeader('Authorization', "Bearer {$token}")
+ ->getJson('/api/team-members?active=true');
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(2);
+
+ // Get only inactive
+ $response = $this->withHeader('Authorization', "Bearer {$token}")
+ ->getJson('/api/team-members?active=false');
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(1);
+ }
+
+ // 2.1.14 API test: PUT /api/team-members/{id} updates member
+ public function test_put_team_members_updates_member()
+ {
+ $token = $this->loginAsManager();
+ $role = Role::factory()->create();
+ $teamMember = TeamMember::factory()->create([
+ 'role_id' => $role->id,
+ 'hourly_rate' => 150.00,
+ ]);
+
+ $response = $this->withHeader('Authorization', "Bearer {$token}")
+ ->putJson("/api/team-members/{$teamMember->id}", [
+ 'hourly_rate' => 175.00,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'id' => $teamMember->id,
+ 'hourly_rate' => '175.00',
+ ]);
+
+ $this->assertDatabaseHas('team_members', [
+ 'id' => $teamMember->id,
+ 'hourly_rate' => '175.00',
+ ]);
+ }
+
+ // 2.1.15 API test: Deactivate sets active=false
+ public function test_deactivate_sets_active_to_false()
+ {
+ $token = $this->loginAsManager();
+ $role = Role::factory()->create();
+ $teamMember = TeamMember::factory()->create([
+ 'role_id' => $role->id,
+ 'active' => true,
+ ]);
+
+ $response = $this->withHeader('Authorization', "Bearer {$token}")
+ ->putJson("/api/team-members/{$teamMember->id}", [
+ 'active' => false,
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'id' => $teamMember->id,
+ 'active' => false,
+ ]);
+
+ $this->assertDatabaseHas('team_members', [
+ 'id' => $teamMember->id,
+ 'active' => false,
+ ]);
+ }
+
+ // 2.1.16 API test: DELETE rejected if allocations exist
+ public function test_delete_rejected_if_allocations_exist()
+ {
+ $token = $this->loginAsManager();
+ $role = Role::factory()->create();
+ $teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
+ $project = Project::factory()->create();
+
+ // Create an allocation for the team member
+ Allocation::factory()->create([
+ 'team_member_id' => $teamMember->id,
+ 'project_id' => $project->id,
+ 'month' => '2024-01',
+ 'allocated_hours' => 40,
+ ]);
+
+ $response = $this->withHeader('Authorization', "Bearer {$token}")
+ ->deleteJson("/api/team-members/{$teamMember->id}");
+
+ $response->assertStatus(422);
+ $response->assertJson([
+ 'message' => 'Cannot delete team member with active allocations',
+ 'suggestion' => 'Consider deactivating the team member instead',
+ ]);
+
+ // Verify the team member still exists
+ $this->assertDatabaseHas('team_members', [
+ 'id' => $teamMember->id,
+ ]);
+ }
+}
diff --git a/backend/tests/Unit/Models/TeamMemberConstraintTest.php b/backend/tests/Unit/Models/TeamMemberConstraintTest.php
new file mode 100644
index 00000000..a940c4b5
--- /dev/null
+++ b/backend/tests/Unit/Models/TeamMemberConstraintTest.php
@@ -0,0 +1,78 @@
+create();
+ $teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
+ $project = Project::factory()->create();
+
+ // Create an allocation for the team member
+ Allocation::factory()->create([
+ 'team_member_id' => $teamMember->id,
+ 'project_id' => $project->id,
+ 'month' => '2024-01',
+ 'allocated_hours' => 40,
+ ]);
+
+ // Verify allocation exists
+ $this->assertTrue($teamMember->fresh()->allocations()->exists());
+
+ // Attempt to delete should be prevented by controller logic
+ // This test documents the constraint behavior
+ $this->assertTrue($teamMember->allocations()->exists());
+ }
+
+ public function test_cannot_delete_team_member_with_actuals()
+ {
+ $role = Role::factory()->create();
+ $teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
+ $project = Project::factory()->create();
+
+ // Create an actual for the team member
+ Actual::factory()->create([
+ 'team_member_id' => $teamMember->id,
+ 'project_id' => $project->id,
+ 'month' => '2024-01',
+ 'hours_logged' => 40,
+ ]);
+
+ // Verify actual exists
+ $this->assertTrue($teamMember->fresh()->actuals()->exists());
+
+ // This test documents the constraint behavior
+ $this->assertTrue($teamMember->actuals()->exists());
+ }
+
+ public function test_can_delete_team_member_without_allocations_or_actuals()
+ {
+ $role = Role::factory()->create();
+ $teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
+
+ // Verify no allocations or actuals
+ $this->assertFalse($teamMember->allocations()->exists());
+ $this->assertFalse($teamMember->actuals()->exists());
+
+ // Delete should succeed
+ $teamMemberId = $teamMember->id;
+ $teamMember->delete();
+
+ $this->assertDatabaseMissing('team_members', [
+ 'id' => $teamMemberId,
+ ]);
+ }
+}
diff --git a/backend/tests/Unit/Models/TeamMemberModelTest.php b/backend/tests/Unit/Models/TeamMemberModelTest.php
new file mode 100644
index 00000000..86d5212d
--- /dev/null
+++ b/backend/tests/Unit/Models/TeamMemberModelTest.php
@@ -0,0 +1,46 @@
+create();
+
+ // Test valid team member
+ $teamMember = TeamMember::factory()->create([
+ 'role_id' => $role->id,
+ 'name' => 'John Doe',
+ 'hourly_rate' => 150.00,
+ 'active' => true,
+ ]);
+
+ $this->assertInstanceOf(TeamMember::class, $teamMember);
+ $this->assertEquals('John Doe', $teamMember->name);
+ $this->assertEquals('150.00', $teamMember->hourly_rate);
+ $this->assertTrue($teamMember->active);
+ $this->assertEquals($role->id, $teamMember->role_id);
+
+ // Test casts
+ $this->assertIsBool($teamMember->active);
+ $this->assertIsString($teamMember->hourly_rate);
+ }
+
+ public function test_team_member_has_role_relationship()
+ {
+ $role = Role::factory()->create(['name' => 'Backend Developer']);
+ $teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
+
+ $this->assertInstanceOf(Role::class, $teamMember->role);
+ $this->assertEquals('Backend Developer', $teamMember->role->name);
+ }
+}
diff --git a/backend/tests/Unit/Policies/TeamMemberPolicyTest.php b/backend/tests/Unit/Policies/TeamMemberPolicyTest.php
new file mode 100644
index 00000000..55726209
--- /dev/null
+++ b/backend/tests/Unit/Policies/TeamMemberPolicyTest.php
@@ -0,0 +1,45 @@
+create(['role' => 'superuser']);
+ $manager = User::factory()->create(['role' => 'manager']);
+ $developer = User::factory()->create(['role' => 'developer']);
+ $teamMember = TeamMember::factory()->create();
+
+ // Superuser can perform all actions
+ $this->actingAs($superuser);
+ $this->assertTrue(Gate::allows('viewAny', TeamMember::class));
+ $this->assertTrue(Gate::allows('view', $teamMember));
+ $this->assertTrue(Gate::allows('create', TeamMember::class));
+ $this->assertTrue(Gate::allows('update', $teamMember));
+ $this->assertTrue(Gate::allows('delete', $teamMember));
+
+ // Manager can perform all actions
+ $this->actingAs($manager);
+ $this->assertTrue(Gate::allows('viewAny', TeamMember::class));
+ $this->assertTrue(Gate::allows('view', $teamMember));
+ $this->assertTrue(Gate::allows('create', TeamMember::class));
+ $this->assertTrue(Gate::allows('update', $teamMember));
+ $this->assertTrue(Gate::allows('delete', $teamMember));
+
+ // Developer can only view
+ $this->actingAs($developer);
+ $this->assertTrue(Gate::allows('viewAny', TeamMember::class));
+ $this->assertTrue(Gate::allows('view', $teamMember));
+ }
+}
diff --git a/frontend/src/lib/services/teamMemberService.ts b/frontend/src/lib/services/teamMemberService.ts
new file mode 100644
index 00000000..7dbb5643
--- /dev/null
+++ b/frontend/src/lib/services/teamMemberService.ts
@@ -0,0 +1,96 @@
+/**
+ * Team Member Service
+ *
+ * API operations for team member management.
+ */
+
+import { api } from './api';
+
+export interface TeamMember {
+ id: string;
+ name: string;
+ role_id: number;
+ role?: {
+ id: number;
+ name: string;
+ };
+ hourly_rate: string;
+ active: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CreateTeamMemberRequest {
+ name: string;
+ role_id: number;
+ hourly_rate: number;
+ active?: boolean;
+}
+
+export interface UpdateTeamMemberRequest {
+ name?: string;
+ role_id?: number;
+ hourly_rate?: number;
+ active?: boolean;
+}
+
+export interface Role {
+ id: number;
+ name: string;
+ description?: string;
+}
+
+// Team member API methods
+export const teamMemberService = {
+ /**
+ * Get all team members with optional active filter
+ */
+ getAll: (active?: boolean) => {
+ const query = active !== undefined ? `?active=${active}` : '';
+ return api.get(`/team-members${query}`);
+ },
+
+ /**
+ * Get a single team member by ID
+ */
+ getById: (id: string) =>
+ api.get(`/team-members/${id}`),
+
+ /**
+ * Create a new team member
+ */
+ create: (data: CreateTeamMemberRequest) =>
+ api.post('/team-members', data),
+
+ /**
+ * Update an existing team member
+ */
+ update: (id: string, data: UpdateTeamMemberRequest) =>
+ api.put(`/team-members/${id}`, data),
+
+ /**
+ * Delete a team member
+ */
+ delete: (id: string) =>
+ api.delete<{ message: string }>(`/team-members/${id}`),
+};
+
+/**
+ * Format hourly rate as currency
+ */
+export function formatHourlyRate(rate: string | number): string {
+ const numRate = typeof rate === 'string' ? parseFloat(rate) : rate;
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ }).format(numRate);
+}
+
+/**
+ * Format hourly rate per hour (e.g., "$150.00/hr")
+ */
+export function formatHourlyRateWithUnit(rate: string | number): string {
+ return `${formatHourlyRate(rate)}/hr`;
+}
+
+export default teamMemberService;
diff --git a/frontend/src/routes/team-members/+page.svelte b/frontend/src/routes/team-members/+page.svelte
index 8ce5f26f..509df898 100644
--- a/frontend/src/routes/team-members/+page.svelte
+++ b/frontend/src/routes/team-members/+page.svelte
@@ -3,47 +3,175 @@
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import DataTable from '$lib/components/common/DataTable.svelte';
import FilterBar from '$lib/components/common/FilterBar.svelte';
- import { Plus } from 'lucide-svelte';
-
- interface TeamMember {
- id: string;
- name: string;
- role: string;
- hourlyRate: number;
- active: boolean;
- }
+ import LoadingState from '$lib/components/common/LoadingState.svelte';
+ import EmptyState from '$lib/components/common/EmptyState.svelte';
+ import { Plus, X, AlertCircle } from 'lucide-svelte';
+ import { teamMemberService, formatHourlyRateWithUnit, type TeamMember, type CreateTeamMemberRequest } from '$lib/services/teamMemberService';
+ import { api } from '$lib/services/api';
+ // State
let data = $state([]);
let loading = $state(true);
+ let error = $state(null);
let search = $state('');
let statusFilter = $state('all');
+ let showModal = $state(false);
+ let editingMember = $state(null);
+ let deleteConfirmMember = $state(null);
+ let formLoading = $state(false);
+ let formError = $state(null);
+ let roles = $state<{ id: number; name: string }[]>([]);
- const columns = [
+ // Form state
+ let formData = $state({
+ name: '',
+ role_id: 0,
+ hourly_rate: 0,
+ active: true
+ });
+
+ import type { ColumnDef } from '@tanstack/table-core';
+
+ const columns: ColumnDef[] = [
+ { accessorKey: 'name', header: 'Name' },
{
- accessorKey: 'name',
- header: 'Name'
+ accessorKey: 'role',
+ header: 'Role',
+ cell: (info) => info.row.original.role?.name || '-'
},
- { accessorKey: 'role', header: 'Role' },
{
- accessorKey: 'hourlyRate',
- header: 'Hourly Rate'
+ accessorKey: 'hourly_rate',
+ header: 'Hourly Rate',
+ cell: (info) => formatHourlyRateWithUnit(info.row.original.hourly_rate)
},
{
accessorKey: 'active',
- header: 'Status'
+ header: 'Status',
+ cell: (info) => getStatusBadge(info.row.original.active)
}
];
onMount(async () => {
- // TODO: Replace with actual API call
- data = [
- { id: '1', name: 'Alice Johnson', role: 'Frontend Dev', hourlyRate: 85, active: true },
- { id: '2', name: 'Bob Smith', role: 'Backend Dev', hourlyRate: 90, active: true },
- { id: '3', name: 'Carol Williams', role: 'Designer', hourlyRate: 75, active: false },
- ];
- loading = false;
+ await Promise.all([loadTeamMembers(), loadRoles()]);
});
+ async function loadTeamMembers() {
+ try {
+ loading = true;
+ error = null;
+ const activeFilter = statusFilter === 'all' ? undefined : statusFilter === 'active';
+ data = await teamMemberService.getAll(activeFilter);
+ } catch (err) {
+ error = err instanceof Error ? err.message : 'Failed to load team members';
+ console.error('Error loading team members:', err);
+ } finally {
+ loading = false;
+ }
+ }
+
+ async function loadRoles() {
+ try {
+ // For now, we'll use hardcoded roles matching the backend seeder
+ // In a real app, you'd fetch this from an API endpoint
+ roles = [
+ { id: 1, name: 'Frontend Developer' },
+ { id: 2, name: 'Backend Developer' },
+ { id: 3, name: 'QA Engineer' },
+ { id: 4, name: 'DevOps Engineer' },
+ { id: 5, name: 'UX Designer' },
+ { id: 6, name: 'Project Manager' },
+ { id: 7, name: 'Architect' }
+ ];
+ } catch (err) {
+ console.error('Error loading roles:', err);
+ }
+ }
+
+ function handleCreate() {
+ editingMember = null;
+ formData = { name: '', role_id: roles[0]?.id || 0, hourly_rate: 0, active: true };
+ formError = null;
+ showModal = true;
+ }
+
+ function handleEdit(row: TeamMember) {
+ editingMember = row;
+ formData = {
+ name: row.name,
+ role_id: row.role_id,
+ hourly_rate: parseFloat(row.hourly_rate),
+ active: row.active
+ };
+ formError = null;
+ showModal = true;
+ }
+
+ function handleDeleteClick(row: TeamMember, event: Event) {
+ event.stopPropagation();
+ deleteConfirmMember = row;
+ }
+
+ async function handleSubmit() {
+ try {
+ formLoading = true;
+ formError = null;
+
+ if (editingMember) {
+ await teamMemberService.update(editingMember.id, formData);
+ } else {
+ await teamMemberService.create(formData);
+ }
+
+ showModal = false;
+ await loadTeamMembers();
+ } catch (err) {
+ const apiError = err as { message?: string; data?: { errors?: Record } };
+ if (apiError.data?.errors) {
+ const errors = Object.entries(apiError.data.errors)
+ .map(([field, msgs]) => `${field}: ${msgs.join(', ')}`)
+ .join('; ');
+ formError = errors;
+ } else {
+ formError = apiError.message || 'An error occurred';
+ }
+ } finally {
+ formLoading = false;
+ }
+ }
+
+ async function handleConfirmDelete() {
+ if (!deleteConfirmMember) return;
+
+ try {
+ formLoading = true;
+ await teamMemberService.delete(deleteConfirmMember.id);
+ deleteConfirmMember = null;
+ await loadTeamMembers();
+ } catch (err) {
+ const apiError = err as { message?: string; suggestion?: string };
+ alert(apiError.message || 'Failed to delete team member');
+ } finally {
+ formLoading = false;
+ }
+ }
+
+ function closeModal() {
+ showModal = false;
+ editingMember = null;
+ formError = null;
+ }
+
+ function closeDeleteModal() {
+ deleteConfirmMember = null;
+ }
+
+ function getStatusBadge(active: boolean) {
+ return active
+ ? 'Active'
+ : 'Inactive';
+ }
+
+ // Reactive filtered data
let filteredData = $derived(data.filter(m => {
const matchesSearch = m.name.toLowerCase().includes(search.toLowerCase());
const matchesStatus = statusFilter === 'all' ||
@@ -52,21 +180,12 @@
return matchesSearch && matchesStatus;
}));
- function handleCreate() {
- // TODO: Open create modal
- console.log('Create team member');
- }
-
- function handleRowClick(row: TeamMember) {
- // TODO: Open edit modal or navigate to detail
- console.log('Edit team member:', row.id);
- }
-
- function getStatusBadge(active: boolean) {
- return active
- ? 'Active'
- : 'Inactive';
- }
+ // Reload when status filter changes
+ $effect(() => {
+ if (statusFilter !== undefined) {
+ loadTeamMembers();
+ }
+ });
@@ -96,11 +215,151 @@
{/snippet}
-
+{#if loading}
+
+{:else if error}
+