Skip to content

Commit 41f0d43

Browse files
committed
wip
1 parent e2555a3 commit 41f0d43

18 files changed

Lines changed: 357 additions & 192 deletions

INTERNAL_CHANGES_GUIDE.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -245,16 +245,16 @@ Numbers are: [insertions] [deletions] [path]
245245
### High-Level Notes by Category
246246
- **Repo hygiene:** Issue/PR templates removed; internal `INTERNAL_CHANGES_GUIDE.md` added; several project policy docs removed.
247247
- **CI:** Multiple GitHub workflows adjusted; some workflows removed; minor 1-line tweaks in remaining workflows.
248-
- **Backend:** Small changes in `ClientController`, `ProjectController`, and `ShareInertiaData` middleware; minor DB config tweak; Composer dependencies updated.
248+
- **Backend:** Client/Project API delete flows are enabled, original guard behavior is restored, and default index ordering matches pre-disable behavior; DB read/write host split remains in place.
249249
- **Docker:** Local and production Docker files removed (compose, Dockerfiles, configs, scripts).
250-
- **Frontend:** Multiple Vue components updated across Clients/Projects tables, dropdowns, and pages; navigation and layout tweaks; utility hooks adjusted.
250+
- **Frontend:** Clients/Projects search is restored, delete actions are available again from row menus, and table heading/status/billable-rate UI behavior is restored.
251251
- **Tests:** e2e tests (`clients.spec.ts`, `projects.spec.ts`) updated.
252252

253253
### API Behavior Changes (Upgrade Notes)
254-
- Clients API: `GET /api/v1/organizations/{org}/clients` now returns clients ordered by `name` ascending (was `created_at` desc). If you rely on ordering, update your consumers accordingly.
255-
- Projects API: `GET /api/v1/organizations/{org}/projects` now returns projects ordered by `name` ascending (was `created_at`-based ordering in some flows). If you relied on creation-time ordering, sort client-side or use a dedicated query param in future versions.
256-
- Clients API: `DELETE /api/v1/organizations/{org}/clients/{client}` is disabled. It now returns `200` with `{ message: "Client deletion disabled" }` and does not delete data.
257-
- Projects API: `DELETE /api/v1/organizations/{org}/projects/{project}` is disabled. It now returns `200` with `{ message: "Project deletion disabled" }` and does not delete data.
254+
- Clients API: `GET /api/v1/organizations/{org}/clients` default ordering is `created_at` descending.
255+
- Projects API: `GET /api/v1/organizations/{org}/projects` keeps creation-time-first ordering semantics (`created_at` descending in the index result).
256+
- Clients API: `DELETE /api/v1/organizations/{org}/clients/{client}` is enabled. It returns `400` when the client is still in use by projects; otherwise it deletes and returns `204`.
257+
- Projects API: `DELETE /api/v1/organizations/{org}/projects/{project}` is enabled. It returns `400` when still in use by tasks/time entries; otherwise it deletes related project members and returns `204`.
258258

259259
### Step-by-Step Protocol (detailed)
260260
1) Clean and position on base (or desired) revision.

app/Http/Controllers/Api/V1/ClientController.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public function index(Organization $organization, ClientIndexRequest $request):
4343

4444
$clientsQuery = Client::query()
4545
->whereBelongsTo($organization, 'organization')
46-
->orderBy('name');
46+
->orderBy('created_at', 'desc');
4747

4848
if (! $canViewAllClients) {
4949
$clientsQuery->visibleByEmployee($user);
@@ -111,7 +111,12 @@ public function destroy(Organization $organization, Client $client): JsonRespons
111111
{
112112
$this->checkPermission($organization, 'clients:delete', $client);
113113

114-
// Deletion disabled: return early to keep data intact
115-
return response()->json(['message' => 'Client deletion disabled'], 200);
114+
if ($client->projects()->exists()) {
115+
throw new EntityStillInUseApiException('client', 'project');
116+
}
117+
118+
$client->delete();
119+
120+
return response()->json(null, 204);
116121
}
117122
}

app/Http/Controllers/Api/V1/ProjectController.php

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
use App\Http\Resources\V1\Project\ProjectResource;
1414
use App\Models\Organization;
1515
use App\Models\Project;
16+
use App\Models\ProjectMember;
1617
use App\Models\TimeEntry;
1718
use App\Service\BillableRateService;
1819
use Illuminate\Auth\Access\AuthorizationException;
1920
use Illuminate\Http\JsonResponse;
2021
use Illuminate\Http\Resources\Json\JsonResource;
2122
use Illuminate\Support\Carbon;
23+
use Illuminate\Support\Facades\DB;
2224

2325
class ProjectController extends Controller
2426
{
@@ -46,8 +48,7 @@ public function index(Organization $organization, ProjectIndexRequest $request):
4648
$user = $this->user();
4749

4850
$projectsQuery = Project::query()
49-
->whereBelongsTo($organization, 'organization')
50-
->orderBy('name');
51+
->whereBelongsTo($organization, 'organization');
5152

5253
if (! $canViewAllProjects) {
5354
$projectsQuery->visibleByEmployee($user);
@@ -168,7 +169,22 @@ public function destroy(Organization $organization, Project $project): JsonRespo
168169
{
169170
$this->checkPermission($organization, 'projects:delete', $project);
170171

171-
// Deletion disabled: return early to keep data intact
172-
return response()->json(['message' => 'Project deletion disabled'], 200);
172+
if ($project->tasks()->exists()) {
173+
throw new EntityStillInUseApiException('project', 'task');
174+
}
175+
if ($project->timeEntries()->exists()) {
176+
throw new EntityStillInUseApiException('project', 'time_entry');
177+
}
178+
179+
DB::transaction(function () use (&$project): void {
180+
$project->members->each(function (ProjectMember $member): void {
181+
$member->delete();
182+
});
183+
184+
$project->delete();
185+
});
186+
187+
return response()
188+
->json(null, 204);
173189
}
174190
}

app/Service/Dto/ReportPropertiesDto.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,8 @@ public function get(Model $model, string $key, mixed $value, array $attributes):
107107
}
108108
}
109109
$dto = new ReportPropertiesDto;
110-
$dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null;
111-
$dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null;
110+
$dto->end = $data->end !== null ? Carbon::parse($data->end, 'UTC') : null;
111+
$dto->start = $data->start !== null ? Carbon::parse($data->start, 'UTC') : null;
112112
$dto->active = $data->active;
113113
$dto->memberIds = $data->memberIds !== null ? ReportPropertiesDto::idArrayToCollection($data->memberIds) : null;
114114
$dto->billable = $data->billable;

resources/js/Components/Common/Client/ClientMoreOptionsDropdown.vue

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
2-
import { ArchiveBoxIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
2+
import { ArchiveBoxIcon, PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid';
33
import type { Client } from '@/packages/api/src';
4-
import { canUpdateClients } from '@/utils/permissions';
4+
import { canDeleteClients, canUpdateClients } from '@/utils/permissions';
55
import {
66
DropdownMenu,
77
DropdownMenuContent,
@@ -57,7 +57,15 @@ const props = defineProps<{
5757
<ArchiveBoxIcon class="w-5 text-icon-active" />
5858
<span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span>
5959
</DropdownMenuItem>
60-
<!-- Delete disabled intentionally -->
60+
<DropdownMenuItem
61+
v-if="canDeleteClients()"
62+
:aria-label="'Delete Client ' + props.client.name"
63+
data-testid="client_delete"
64+
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
65+
@click="emit('delete')">
66+
<TrashIcon class="w-5" />
67+
<span>Delete</span>
68+
</DropdownMenuItem>
6169
</DropdownMenuContent>
6270
</DropdownMenu>
6371
</template>

resources/js/Components/Common/Client/ClientTableRow.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ const props = defineProps<{
1414
client: Client;
1515
}>();
1616
17+
function deleteClient() {
18+
useClientsStore().deleteClient(props.client.id);
19+
}
20+
1721
const projectCount = computed(() => {
1822
return projects.value.filter((projects) => projects.client_id === props.client.id).length;
1923
});
@@ -56,6 +60,7 @@ const showEditModal = ref(false);
5660
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
5761
<ClientMoreOptionsDropdown
5862
:client="client"
63+
@delete="deleteClient"
5964
@edit="showEditModal = true"
6065
@archive="archiveClient"></ClientMoreOptionsDropdown>
6166
</div>

resources/js/Components/Common/Project/ProjectMoreOptionsDropdown.vue

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
2-
import { PencilSquareIcon, ArchiveBoxIcon } from '@heroicons/vue/20/solid';
2+
import { TrashIcon, PencilSquareIcon, ArchiveBoxIcon } from '@heroicons/vue/20/solid';
33
import type { Project } from '@/packages/api/src';
4-
import { canUpdateProjects } from '@/utils/permissions';
4+
import { canDeleteProjects, canUpdateProjects } from '@/utils/permissions';
55
import {
66
DropdownMenu,
77
DropdownMenuContent,
@@ -57,7 +57,15 @@ const props = defineProps<{
5757
<ArchiveBoxIcon class="w-5 text-icon-active" />
5858
<span>{{ project.is_archived ? 'Unarchive' : 'Archive' }}</span>
5959
</DropdownMenuItem>
60-
<!-- Delete disabled intentionally -->
60+
<DropdownMenuItem
61+
v-if="canDeleteProjects()"
62+
:aria-label="'Delete Project ' + props.project.name"
63+
data-testid="project_delete"
64+
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
65+
@click.prevent="emit('delete')">
66+
<TrashIcon class="w-5" />
67+
<span>Delete</span>
68+
</DropdownMenuItem>
6169
</DropdownMenuContent>
6270
</DropdownMenu>
6371
</template>

resources/js/Components/Common/Project/ProjectTableRow.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ const projectTasksCount = computed(() => {
3232
return tasks.value.filter((task) => task.project_id === props.project.id).length;
3333
});
3434
35+
function deleteProject() {
36+
useProjectsStore().deleteProject(props.project.id);
37+
}
38+
3539
function archiveProject() {
3640
useProjectsStore().updateProject(props.project.id, {
3741
...props.project,
@@ -125,6 +129,7 @@ const showEditProjectModal = ref(false);
125129
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
126130
<ProjectMoreOptionsDropdown
127131
:project="project"
132+
@delete="deleteProject"
128133
@edit="showEditProjectModal = true"
129134
@archive="archiveProject"></ProjectMoreOptionsDropdown>
130135
</div>

resources/js/Components/Common/Reporting/ReportingFilterBar.vue

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
33
import { FolderIcon } from '@heroicons/vue/16/solid';
44
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
5+
import InvoiceStatusIcon from '@/packages/ui/src/Icons/InvoiceStatusIcon.vue';
56
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
67
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
78
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
@@ -29,6 +30,7 @@ const selectedTasks = defineModel<string[]>('selectedTasks', { required: true })
2930
const selectedClients = defineModel<string[]>('selectedClients', { required: true });
3031
const selectedTags = defineModel<string[]>('selectedTags', { required: true });
3132
const billable = defineModel<'true' | 'false' | null>('billable', { required: true });
33+
const invoiced = defineModel<'true' | 'false' | null>('invoiced', { default: null });
3234
const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });
3335
const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });
3436
const roundingMinutes = defineModel<number>('roundingMinutes', { required: true });
@@ -126,6 +128,32 @@ async function createTag(name: string) {
126128
<SelectItem value="false">Non Billable</SelectItem>
127129
</SelectContent>
128130
</Select>
131+
<Select v-model="invoiced" @update:model-value="emit('submit')">
132+
<SelectTrigger
133+
size="sm"
134+
variant="outline"
135+
:active="invoiced !== null"
136+
:show-chevron="false">
137+
<SelectValue class="flex items-center gap-2">
138+
<InvoiceStatusIcon
139+
size="small"
140+
:invoiced="invoiced === 'true'"
141+
:class="
142+
invoiced !== null
143+
? 'dark:text-accent-300/80 text-accent-400/80'
144+
: 'text-text-quaternary'
145+
" />
146+
<span class="text-text-secondary">{{
147+
invoiced === 'false' ? 'Not Invoiced' : 'Invoiced'
148+
}}</span>
149+
</SelectValue>
150+
</SelectTrigger>
151+
<SelectContent>
152+
<SelectItem :value="null">Both</SelectItem>
153+
<SelectItem value="true">Invoiced</SelectItem>
154+
<SelectItem value="false">Not Invoiced</SelectItem>
155+
</SelectContent>
156+
</Select>
129157
<ReportingRoundingControls
130158
v-model:enabled="roundingEnabled"
131159
v-model:type="roundingType"

resources/js/Components/Common/Reporting/ReportingOverview.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const selectedTasks = ref<string[]>([]);
6969
const selectedClients = ref<string[]>([]);
7070
7171
const billable = ref<'true' | 'false' | null>(null);
72+
const invoiced = ref<'true' | 'false' | null>(null);
7273
const roundingEnabled = ref<boolean>(false);
7374
const roundingType = ref<TimeEntryRoundingType>('nearest');
7475
const roundingMinutes = ref<number>(15);
@@ -123,6 +124,7 @@ const filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {
123124
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
124125
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
125126
billable: billable.value !== null ? billable.value : undefined,
127+
invoiced: invoiced.value !== null ? invoiced.value : undefined,
126128
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
127129
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
128130
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
@@ -367,6 +369,7 @@ const tableData = computed(() => {
367369
v-model:selected-clients="selectedClients"
368370
v-model:selected-tags="selectedTags"
369371
v-model:billable="billable"
372+
v-model:invoiced="invoiced"
370373
v-model:rounding-enabled="roundingEnabled"
371374
v-model:rounding-type="roundingType"
372375
v-model:rounding-minutes="roundingMinutes"

0 commit comments

Comments
 (0)