From 0463777fbd878ba69baff931d65c6e327bddd8c7 Mon Sep 17 00:00:00 2001 From: manar17 Date: Tue, 24 Mar 2026 23:07:05 +0200 Subject: [PATCH 1/7] finishing up with task2 --- .../Http/Controllers/Api/AuthController.php | 1 + .../Controllers/Api/ChannelController.php | 64 +++++ .../Controllers/Api/CommentController.php | 53 ++++ .../Controllers/Api/InvitationController.php | 70 ++++++ .../app/Http/Controllers/Api/MeController.php | 43 ++++ .../Http/Controllers/Api/ReportController.php | 53 ++++ .../Controllers/Api/SharedDayController.php | 69 ++++++ .../Controllers/Api/TimeEntryController.php | 25 +- .../Http/Controllers/Api/UserController.php | 54 +++++ backend/app/Http/Requests/ReportRequest.php | 29 +++ .../app/Http/Requests/StoreChannelRequest.php | 29 +++ .../app/Http/Requests/StoreCommentRequest.php | 28 +++ .../Http/Requests/StoreInvitationRequest.php | 28 +++ .../Http/Requests/StoreSharedDayRequest.php | 53 ++++ .../Http/Requests/UpdateChannelRequest.php | 28 +++ .../Http/Requests/UpdateCommentRequest.php | 28 +++ .../Http/Requests/UpdateInvitationRequest.php | 25 ++ .../app/Http/Resources/ChannelResource.php | 19 ++ .../app/Http/Resources/CommentResource.php | 25 ++ .../app/Http/Resources/InvitationResource.php | 19 ++ .../Http/Resources/SharedDayEntryResource.php | 23 ++ .../app/Http/Resources/SharedDayResource.php | 22 ++ .../app/Http/Resources/TimeEntryResource.php | 2 +- backend/app/Http/Resources/UserResource.php | 19 ++ backend/app/Models/Channel.php | 77 ++++++ backend/app/Models/Comment.php | 27 +++ backend/app/Models/Invitation.php | 54 +++++ backend/app/Models/InvitationLog.php | 20 ++ backend/app/Models/Member.php | 24 ++ backend/app/Models/SharedDay.php | 61 +++++ backend/app/Models/SharedDayEntry.php | 24 ++ backend/app/Models/TimeEntry.php | 60 +++-- backend/app/Models/User.php | 35 ++- backend/app/Observers/InvitationObserver.php | 38 +++ backend/app/Observers/TimeEntryObserver.php | 18 ++ backend/app/Policies/ChannelPolicy.php | 34 +++ backend/app/Policies/CommentPolicy.php | 28 +++ backend/app/Policies/InvitationPolicy.php | 51 ++++ backend/app/Policies/SharedDayPolicy.php | 32 +++ backend/app/Providers/AppServiceProvider.php | 8 +- .../app/Services/InvitationStatusService.php | 63 +++++ backend/app/Services/InviteService.php | 81 +++++++ backend/app/Services/ReportService.php | 138 +++++++++++ backend/composer.json | 3 +- backend/composer.lock | 65 ++++- backend/database/factories/ChannelFactory.php | 24 ++ .../database/factories/TimeEntryFactory.php | 1 + ...026_02_27_201248_create_channels_table.php | 30 +++ ...2026_02_27_201308_create_members_table.php | 30 +++ ..._02_27_201329_create_invitations_table.php | 27 +++ ..._02_27_201625_create_shared_days_table.php | 31 +++ ...201637_create_shared_day_entries_table.php | 30 +++ ...026_02_27_201648_create_comments_table.php | 31 +++ ...01_232740_create_invitation_logs_table.php | 31 +++ ...unique_constraint_to_shared_days_table.php | 25 ++ ...duration_minutes_to_time_entries_table.php | 25 ++ backend/database/seeders/ChannelSeeder.php | 21 ++ backend/database/seeders/DatabaseSeeder.php | 9 +- backend/database/seeders/InvitationSeeder.php | 58 +++++ backend/database/seeders/MemberSeeder.php | 32 +++ backend/routes/api.php | 56 +++-- frontend/src/App.vue | 86 +++++-- frontend/src/components/BarChart.vue | 53 ---- frontend/src/components/ListView.vue | 27 --- frontend/src/components/NavBar.vue | 103 +++++--- .../src/components/channel/ChannelCard.vue | 48 ++++ .../components/channel/ChannelOverview.vue | 65 +++++ .../components/channel/ChannelSettings.vue | 57 +++++ .../channel/members/ChannelMembers.vue | 99 ++++++++ .../channel/members/InviteMemberModal.vue | 117 +++++++++ .../members/PendingInvitationsList.vue | 28 +++ .../channel/shared-days/AddComment.vue | 49 ++++ .../channel/shared-days/ChannelSharedDays.vue | 53 ++++ .../channel/shared-days/CommentItem.vue | 137 +++++++++++ .../channel/shared-days/CommentsSection.vue | 77 ++++++ .../channel/shared-days/SharedDayCard.vue | 103 ++++++++ .../channel/shared-days/SharedDayDetails.vue | 90 +++++++ frontend/src/components/charts/BarChart.vue | 23 ++ .../src/components/charts/DoughnutChart.vue | 23 ++ .../components/charts/HorizontalBarChart.vue | 24 ++ .../components/history/HistoryDateItem.vue | 21 ++ .../src/components/history/HistorySidebar.vue | 99 ++++++++ .../components/invitations/InvitationCard.vue | 87 +++++++ .../components/time-entries/ShareDayModal.vue | 196 +++++++++++++++ .../components/time-entries/SingleEntry.vue | 129 ++++++++++ .../components/time-entries/TimeEntryList.vue | 29 +++ frontend/src/composables/usePaginate.js | 18 ++ frontend/src/locales/ar.json | 227 ++++++++++++++++++ frontend/src/locales/en.json | 226 +++++++++++++++++ frontend/src/main.js | 131 +--------- frontend/src/router/index.js | 58 ++++- frontend/src/services/api.js | 43 ++-- frontend/src/services/channelService.js | 15 ++ frontend/src/services/commentService.js | 16 ++ frontend/src/services/invitationService.js | 29 +++ frontend/src/services/reportService.js | 3 + frontend/src/services/sharedDayService.js | 18 ++ frontend/src/services/timeEntryService.js | 2 + frontend/src/stores/commentStore.js | 72 ++++++ frontend/src/views/About.vue | 17 +- frontend/src/views/AddForm.vue | 92 ++++--- frontend/src/views/ChannelDashboard.vue | 97 ++++++++ frontend/src/views/ChannelList.vue | 105 ++++++++ frontend/src/views/CreateChannelForm.vue | 92 +++++++ frontend/src/views/History.vue | 21 -- frontend/src/views/Home.vue | 109 --------- frontend/src/views/HomeView.vue | 169 +++++++++++++ frontend/src/views/InvitationsView.vue | 42 ++++ frontend/src/views/Login.vue | 141 ++++++----- frontend/src/views/MySharedDays.vue | 85 +++++++ frontend/src/views/Register.vue | 197 ++++++++------- frontend/src/views/ReportsView.vue | 207 ++++++++++++++++ frontend/src/views/SingleEntry.vue | 59 ----- 113 files changed, 5626 insertions(+), 699 deletions(-) create mode 100644 backend/app/Http/Controllers/Api/ChannelController.php create mode 100644 backend/app/Http/Controllers/Api/CommentController.php create mode 100644 backend/app/Http/Controllers/Api/InvitationController.php create mode 100644 backend/app/Http/Controllers/Api/MeController.php create mode 100644 backend/app/Http/Controllers/Api/ReportController.php create mode 100644 backend/app/Http/Controllers/Api/SharedDayController.php create mode 100644 backend/app/Http/Controllers/Api/UserController.php create mode 100644 backend/app/Http/Requests/ReportRequest.php create mode 100644 backend/app/Http/Requests/StoreChannelRequest.php create mode 100644 backend/app/Http/Requests/StoreCommentRequest.php create mode 100644 backend/app/Http/Requests/StoreInvitationRequest.php create mode 100644 backend/app/Http/Requests/StoreSharedDayRequest.php create mode 100644 backend/app/Http/Requests/UpdateChannelRequest.php create mode 100644 backend/app/Http/Requests/UpdateCommentRequest.php create mode 100644 backend/app/Http/Requests/UpdateInvitationRequest.php create mode 100644 backend/app/Http/Resources/ChannelResource.php create mode 100644 backend/app/Http/Resources/CommentResource.php create mode 100644 backend/app/Http/Resources/InvitationResource.php create mode 100644 backend/app/Http/Resources/SharedDayEntryResource.php create mode 100644 backend/app/Http/Resources/SharedDayResource.php create mode 100644 backend/app/Http/Resources/UserResource.php create mode 100644 backend/app/Models/Channel.php create mode 100644 backend/app/Models/Comment.php create mode 100644 backend/app/Models/Invitation.php create mode 100644 backend/app/Models/InvitationLog.php create mode 100644 backend/app/Models/Member.php create mode 100644 backend/app/Models/SharedDay.php create mode 100644 backend/app/Models/SharedDayEntry.php create mode 100644 backend/app/Observers/InvitationObserver.php create mode 100644 backend/app/Observers/TimeEntryObserver.php create mode 100644 backend/app/Policies/ChannelPolicy.php create mode 100644 backend/app/Policies/CommentPolicy.php create mode 100644 backend/app/Policies/InvitationPolicy.php create mode 100644 backend/app/Policies/SharedDayPolicy.php create mode 100644 backend/app/Services/InvitationStatusService.php create mode 100644 backend/app/Services/InviteService.php create mode 100644 backend/app/Services/ReportService.php create mode 100644 backend/database/factories/ChannelFactory.php create mode 100644 backend/database/migrations/2026_02_27_201248_create_channels_table.php create mode 100644 backend/database/migrations/2026_02_27_201308_create_members_table.php create mode 100644 backend/database/migrations/2026_02_27_201329_create_invitations_table.php create mode 100644 backend/database/migrations/2026_02_27_201625_create_shared_days_table.php create mode 100644 backend/database/migrations/2026_02_27_201637_create_shared_day_entries_table.php create mode 100644 backend/database/migrations/2026_02_27_201648_create_comments_table.php create mode 100644 backend/database/migrations/2026_03_01_232740_create_invitation_logs_table.php create mode 100644 backend/database/migrations/2026_03_11_212218_add_unique_constraint_to_shared_days_table.php create mode 100644 backend/database/migrations/2026_03_20_200524_add_duration_minutes_to_time_entries_table.php create mode 100644 backend/database/seeders/ChannelSeeder.php create mode 100644 backend/database/seeders/InvitationSeeder.php create mode 100644 backend/database/seeders/MemberSeeder.php delete mode 100644 frontend/src/components/BarChart.vue delete mode 100644 frontend/src/components/ListView.vue create mode 100644 frontend/src/components/channel/ChannelCard.vue create mode 100644 frontend/src/components/channel/ChannelOverview.vue create mode 100644 frontend/src/components/channel/ChannelSettings.vue create mode 100644 frontend/src/components/channel/members/ChannelMembers.vue create mode 100644 frontend/src/components/channel/members/InviteMemberModal.vue create mode 100644 frontend/src/components/channel/members/PendingInvitationsList.vue create mode 100644 frontend/src/components/channel/shared-days/AddComment.vue create mode 100644 frontend/src/components/channel/shared-days/ChannelSharedDays.vue create mode 100644 frontend/src/components/channel/shared-days/CommentItem.vue create mode 100644 frontend/src/components/channel/shared-days/CommentsSection.vue create mode 100644 frontend/src/components/channel/shared-days/SharedDayCard.vue create mode 100644 frontend/src/components/channel/shared-days/SharedDayDetails.vue create mode 100644 frontend/src/components/charts/BarChart.vue create mode 100644 frontend/src/components/charts/DoughnutChart.vue create mode 100644 frontend/src/components/charts/HorizontalBarChart.vue create mode 100644 frontend/src/components/history/HistoryDateItem.vue create mode 100644 frontend/src/components/history/HistorySidebar.vue create mode 100644 frontend/src/components/invitations/InvitationCard.vue create mode 100644 frontend/src/components/time-entries/ShareDayModal.vue create mode 100644 frontend/src/components/time-entries/SingleEntry.vue create mode 100644 frontend/src/components/time-entries/TimeEntryList.vue create mode 100644 frontend/src/composables/usePaginate.js create mode 100644 frontend/src/locales/ar.json create mode 100644 frontend/src/locales/en.json create mode 100644 frontend/src/services/channelService.js create mode 100644 frontend/src/services/commentService.js create mode 100644 frontend/src/services/invitationService.js create mode 100644 frontend/src/services/reportService.js create mode 100644 frontend/src/services/sharedDayService.js create mode 100644 frontend/src/stores/commentStore.js create mode 100644 frontend/src/views/ChannelDashboard.vue create mode 100644 frontend/src/views/ChannelList.vue create mode 100644 frontend/src/views/CreateChannelForm.vue delete mode 100644 frontend/src/views/History.vue delete mode 100644 frontend/src/views/Home.vue create mode 100644 frontend/src/views/HomeView.vue create mode 100644 frontend/src/views/InvitationsView.vue create mode 100644 frontend/src/views/MySharedDays.vue create mode 100644 frontend/src/views/ReportsView.vue delete mode 100644 frontend/src/views/SingleEntry.vue diff --git a/backend/app/Http/Controllers/Api/AuthController.php b/backend/app/Http/Controllers/Api/AuthController.php index 000d455..2208c08 100644 --- a/backend/app/Http/Controllers/Api/AuthController.php +++ b/backend/app/Http/Controllers/Api/AuthController.php @@ -54,4 +54,5 @@ public function logout(Request $request){ $user->tokens()->delete(); return response()->json(["message" => "Logged out successfully " . $user->name]); } + } diff --git a/backend/app/Http/Controllers/Api/ChannelController.php b/backend/app/Http/Controllers/Api/ChannelController.php new file mode 100644 index 0000000..aa02b35 --- /dev/null +++ b/backend/app/Http/Controllers/Api/ChannelController.php @@ -0,0 +1,64 @@ +authorizeResource(Channel::class, 'channel'); + } + + + + + public function index() + { + $owned = Channel::owned(auth()->user())->latest()->get(); + $joined = Channel::joined(auth()->user())->latest()->get(); + return response()->json([ + 'owned' => ChannelResource::collection($owned), + 'joined' => ChannelResource::collection($joined), + ]); + } + + /** + * Store a newly created resource in storage. + */ + public function store(StoreChannelRequest $request) + { + $channel = auth()->user()->channels()->create($request->validated()); + return new ChannelResource($channel); + } + + /** + * Display the specified resource. + */ + public function show(Channel $channel) + { + return new ChannelResource($channel->load(['members.user', 'invitations'])); + } + + /** + * Update the specified resource in storage. + */ + public function update(UpdateChannelRequest $request, Channel $channel) + { + $validated = $request->validated(); + $channel->update($validated); + return new ChannelResource($channel); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Channel $channel) + { + $channel->delete(); + return new ChannelResource($channel); + } +} diff --git a/backend/app/Http/Controllers/Api/CommentController.php b/backend/app/Http/Controllers/Api/CommentController.php new file mode 100644 index 0000000..f68e98c --- /dev/null +++ b/backend/app/Http/Controllers/Api/CommentController.php @@ -0,0 +1,53 @@ +authorize('viewAny', [Comment::class, $channel]); + + return CommentResource::collection( + $sharedDay->comments() + ->with(['author', 'sharedDay.channel']) + ->latest() + ->paginate(1) + ); + } + + public function store(StoreCommentRequest $request, Channel $channel, SharedDay $sharedDay) + { + $this->authorize('create', [Comment::class, $channel]); + + $comment = $sharedDay->comments()->create([ + ...$request->validated(), + 'user_id' => auth()->id() + ]); + + return new CommentResource($comment->load(['author', 'sharedDay.channel'])); + } + + public function update(UpdateCommentRequest $request, Channel $channel, SharedDay $sharedDay, Comment $comment) + { + $this->authorize('update', $comment); + + $comment->update($request->validated()); + + return new CommentResource($comment->load(['author', 'sharedDay.channel'])); + } + + public function destroy(Channel $channel, SharedDay $sharedDay, Comment $comment) + { + $this->authorize('delete', [$comment, $channel]); + + $comment->delete(); + + return response()->json(['message' => 'Comment removed successfully.']); + } +} \ No newline at end of file diff --git a/backend/app/Http/Controllers/Api/InvitationController.php b/backend/app/Http/Controllers/Api/InvitationController.php new file mode 100644 index 0000000..11cae23 --- /dev/null +++ b/backend/app/Http/Controllers/Api/InvitationController.php @@ -0,0 +1,70 @@ +authorize('viewAny', [Invitation::class, $channel]); + $invitations = Invitation::query() + ->forChannel($channel) + ->pending() + ->with(['invitedUser', 'channel']) + ->latest() + ->paginate(10); + + + return InvitationResource::collection($invitations); + } + + + + public function store(StoreInvitationRequest $request, Channel $channel, InviteService $service ) + { + $validated = $request->validated(); + $identifier = $validated['identifier']; + $invitation = $service->invite($identifier, $channel); + return new InvitationResource($invitation); + } + + /** + * Display the specified resource. + */ + public function show(string $id) + { + // + } + + /** + * Update the specified resource in storage. + */ + public function update(UpdateInvitationRequest $request, Channel $channel, Invitation $invitation, InvitationStatusService $service) + { + $validated = $request->validated(); + $status = $validated['status']; + + match ($status) { + 'accepted' => $this->authorize('accept', $invitation), + 'declined' => $this->authorize('decline', $invitation), + 'cancelled' => $this->authorize('cancel', $invitation), + }; + + $service->changeStatus($invitation, $status, auth()->user()); + return new InvitationResource($invitation); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(string $id) + { + // + } +} diff --git a/backend/app/Http/Controllers/Api/MeController.php b/backend/app/Http/Controllers/Api/MeController.php new file mode 100644 index 0000000..a411309 --- /dev/null +++ b/backend/app/Http/Controllers/Api/MeController.php @@ -0,0 +1,43 @@ +received($request->user()) + ->pending() + ->with(['invitedBy', 'channel']) + ->latest() + ->paginate(); + + return InvitationResource::collection($invitations); + } + + public function sharedDays() + { + $sharedDays = auth()->user() + ->sharedDays() + ->with('channel') + ->get() + ->groupBy('date'); + + return $sharedDays->map(function ($days, $date) { + return [ + 'date' => $date, + 'channels' => $days->map(fn($day) => [ + 'id' => $day->channel->id, + 'name' => $day->channel->name, + 'shared_day_id' => $day->id, + ]) + ]; + })->values(); + } +} \ No newline at end of file diff --git a/backend/app/Http/Controllers/Api/ReportController.php b/backend/app/Http/Controllers/Api/ReportController.php new file mode 100644 index 0000000..bfbabe8 --- /dev/null +++ b/backend/app/Http/Controllers/Api/ReportController.php @@ -0,0 +1,53 @@ +generate(auth()->id(), + $request->input('from'), + $request->input('to'), + $request->boolean('refresh')); + } + + /** + * Store a newly created resource in storage. + */ + public function store(Request $request) + { + // + } + + /** + * Display the specified resource. + */ + public function show(string $id) + { + // + } + + /** + * Update the specified resource in storage. + */ + public function update(Request $request, string $id) + { + // + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(string $id) + { + // + } +} diff --git a/backend/app/Http/Controllers/Api/SharedDayController.php b/backend/app/Http/Controllers/Api/SharedDayController.php new file mode 100644 index 0000000..a2f514c --- /dev/null +++ b/backend/app/Http/Controllers/Api/SharedDayController.php @@ -0,0 +1,69 @@ +authorize('viewAny', $channel); + + return SharedDayResource::collection( + $channel->sharedDays()->get() + ); + } + + // GET /channels/{channel}/shared-days/{shared_day} + public function show(Channel $channel, SharedDay $sharedDay) + { + $this->authorize('view', $channel); + + return new SharedDayResource($sharedDay->load(['entries.timeEntry', 'channel'])); + } + + // POST /shared-days + public function store(StoreSharedDayRequest $request) + { + $sharedDays = collect(); + + DB::transaction(function () use ($request, &$sharedDays) { + foreach ($request->channel_ids as $channelId) { + $channel = Channel::findOrFail($channelId); + $this->authorize('create', [SharedDay::class, $channel]); + + $sharedDay = SharedDay::firstOrCreate([ + 'channel_id' => $channelId, + 'user_id' => auth()->id(), + 'date' => $request->date, + ]); + + foreach ($request->entry_ids as $entryId) { + $sharedDay->entries()->firstOrCreate([ + 'time_entry_id' => $entryId + ]); + } + + $sharedDay->load(['entries.timeEntry', 'channel']); + $sharedDays->push($sharedDay); + } + }); + + return SharedDayResource::collection($sharedDays); + } + + // DELETE /channels/{channel}/shared-days/{shared_day} + public function destroy(Channel $channel, SharedDay $sharedDay) + { + $this->authorize('delete', $sharedDay); + + $sharedDay->delete(); + return response()->json(['message' => 'Shared day removed successfully.']); + } +} \ No newline at end of file diff --git a/backend/app/Http/Controllers/Api/TimeEntryController.php b/backend/app/Http/Controllers/Api/TimeEntryController.php index 26fb53c..21811d9 100644 --- a/backend/app/Http/Controllers/Api/TimeEntryController.php +++ b/backend/app/Http/Controllers/Api/TimeEntryController.php @@ -1,10 +1,9 @@ only(['date', 'sort', 'search', 'history']); + if ($request->boolean('history')) { $dates = auth()->user() ->time_entries() ->history() - ->pluck('date'); + ->paginate(15); - return response()->json($dates); + return response()->json([ + 'data' => $dates->pluck('date'), + 'current_page' => $dates->currentPage(), + 'last_page' => $dates->lastPage(), + 'per_page' => $dates->perPage(), + 'total' => $dates->total(), + 'from' => $dates->firstItem(), + 'to' => $dates->lastItem(), + ]); } $time_entries = auth()->user() ->time_entries() ->search($filters) - ->paginate(15); + ->paginate(10); return TimeEntryResource::collection($time_entries); } + public function store(StoreTimeEntryRequest $request) { $validated = $request->validated(); @@ -54,4 +63,4 @@ public function destroy(TimeEntry $time_entry) $time_entry->delete(); return new TimeEntryResource($time_entry); } -} \ No newline at end of file +} diff --git a/backend/app/Http/Controllers/Api/UserController.php b/backend/app/Http/Controllers/Api/UserController.php new file mode 100644 index 0000000..9d1623a --- /dev/null +++ b/backend/app/Http/Controllers/Api/UserController.php @@ -0,0 +1,54 @@ +input("search"); + $query = User::query(); + $users = $query->searchByNameOrEmail($identifier)->get(); + return UserResource::collection($users); + + } + + /** + * Store a newly created resource in storage. + */ + public function store(Request $request) + { + // + } + + /** + * Display the specified resource. + */ + public function show(string $id) + { + // + } + + /** + * Update the specified resource in storage. + */ + public function update(Request $request, string $id) + { + // + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(string $id) + { + // + } +} diff --git a/backend/app/Http/Requests/ReportRequest.php b/backend/app/Http/Requests/ReportRequest.php new file mode 100644 index 0000000..8a8701c --- /dev/null +++ b/backend/app/Http/Requests/ReportRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'from' => 'nullable|date|date_format:Y-m-d', + 'to' => 'nullable|date|date_format:Y-m-d|after_or_equal:from' + ]; + } +} diff --git a/backend/app/Http/Requests/StoreChannelRequest.php b/backend/app/Http/Requests/StoreChannelRequest.php new file mode 100644 index 0000000..1ca842f --- /dev/null +++ b/backend/app/Http/Requests/StoreChannelRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + "name" => "required|max:255", + + ]; + } +} diff --git a/backend/app/Http/Requests/StoreCommentRequest.php b/backend/app/Http/Requests/StoreCommentRequest.php new file mode 100644 index 0000000..6d668cd --- /dev/null +++ b/backend/app/Http/Requests/StoreCommentRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + "body"=>"required|string|min:1|max:1000" + ]; + } +} diff --git a/backend/app/Http/Requests/StoreInvitationRequest.php b/backend/app/Http/Requests/StoreInvitationRequest.php new file mode 100644 index 0000000..0dbc74f --- /dev/null +++ b/backend/app/Http/Requests/StoreInvitationRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'identifier' => 'required|string', + ]; + } +} diff --git a/backend/app/Http/Requests/StoreSharedDayRequest.php b/backend/app/Http/Requests/StoreSharedDayRequest.php new file mode 100644 index 0000000..345bba7 --- /dev/null +++ b/backend/app/Http/Requests/StoreSharedDayRequest.php @@ -0,0 +1,53 @@ + ['required', 'date'], + 'channel_ids' => ['required', 'array', 'min:1'], + 'channel_ids.*' => ['required', 'exists:channels,id'], + 'entry_ids' => ['required', 'array', 'min:1'], + 'entry_ids.*' => ['required', 'exists:time_entries,id'], + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator) { + + $hasEntries = TimeEntry::where('user_id', auth()->id()) + ->date($this->date) + ->exists(); + + if (!$hasEntries) { + $validator->errors()->add('date', 'This day has no time entries.'); + } + + + if ($this->entry_ids) { + $validCount = TimeEntry::where('user_id', auth()->id()) + ->date($this->date) + ->whereIn('id', $this->entry_ids) + ->count(); + + if ($validCount !== count($this->entry_ids)) { + $validator->errors()->add( + 'entry_ids', + 'Some entries do not belong to this day or to you.' + ); + } + } + }); + } +} \ No newline at end of file diff --git a/backend/app/Http/Requests/UpdateChannelRequest.php b/backend/app/Http/Requests/UpdateChannelRequest.php new file mode 100644 index 0000000..c66531b --- /dev/null +++ b/backend/app/Http/Requests/UpdateChannelRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + "name" => "sometimes|max:255", + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateCommentRequest.php b/backend/app/Http/Requests/UpdateCommentRequest.php new file mode 100644 index 0000000..13d2988 --- /dev/null +++ b/backend/app/Http/Requests/UpdateCommentRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + "body"=> "sometimes|string|min:1|max:255" + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateInvitationRequest.php b/backend/app/Http/Requests/UpdateInvitationRequest.php new file mode 100644 index 0000000..f7de4e5 --- /dev/null +++ b/backend/app/Http/Requests/UpdateInvitationRequest.php @@ -0,0 +1,25 @@ +|string> + */ + public function rules(): array + { + return [ + 'status' => ['required', 'in:accepted,declined,cancelled'], + ]; + } +} diff --git a/backend/app/Http/Resources/ChannelResource.php b/backend/app/Http/Resources/ChannelResource.php new file mode 100644 index 0000000..550b90a --- /dev/null +++ b/backend/app/Http/Resources/ChannelResource.php @@ -0,0 +1,19 @@ + + */ + public function toArray(Request $request): array + { + return parent::toArray($request); + } +} diff --git a/backend/app/Http/Resources/CommentResource.php b/backend/app/Http/Resources/CommentResource.php new file mode 100644 index 0000000..98e27c7 --- /dev/null +++ b/backend/app/Http/Resources/CommentResource.php @@ -0,0 +1,25 @@ + $this->id, + 'body' => $this->body, + 'created_at' => $this->created_at->format('M d, Y h:i A'), + "shared_day_id" => $this->shared_day_id, + 'author' => new UserResource($this->whenLoaded('author')), + 'can_edit' => auth()->id() === $this->user_id, + 'can_delete' => auth()->id() === $this->user_id || + $this->whenLoaded('sharedDay', fn() => + $this->sharedDay->channel->isOwner(auth()->user()) + , false) + ]; + } +} diff --git a/backend/app/Http/Resources/InvitationResource.php b/backend/app/Http/Resources/InvitationResource.php new file mode 100644 index 0000000..6d7f8b7 --- /dev/null +++ b/backend/app/Http/Resources/InvitationResource.php @@ -0,0 +1,19 @@ + + */ + public function toArray(Request $request): array + { + return parent::toArray($request); + } +} diff --git a/backend/app/Http/Resources/SharedDayEntryResource.php b/backend/app/Http/Resources/SharedDayEntryResource.php new file mode 100644 index 0000000..496e59f --- /dev/null +++ b/backend/app/Http/Resources/SharedDayEntryResource.php @@ -0,0 +1,23 @@ + + */ + public function toArray($request): array + { + return [ + 'id' => $this->id, + 'time_entry' => new TimeEntryResource($this->timeEntry) + ]; + } +} diff --git a/backend/app/Http/Resources/SharedDayResource.php b/backend/app/Http/Resources/SharedDayResource.php new file mode 100644 index 0000000..fc16f66 --- /dev/null +++ b/backend/app/Http/Resources/SharedDayResource.php @@ -0,0 +1,22 @@ + $this->id, + 'date' => $this->date->format('Y-m-d'), + 'channel_id' => $this->channel_id, + 'total_time' => $this->total_time, + 'entries_count' => $this->entries_count, + 'entries' => SharedDayEntryResource::collection($this->whenLoaded('entries')), + 'channel' => new ChannelResource($this->whenLoaded('channel')), + ]; + } +} \ No newline at end of file diff --git a/backend/app/Http/Resources/TimeEntryResource.php b/backend/app/Http/Resources/TimeEntryResource.php index 9d14946..96bdabd 100644 --- a/backend/app/Http/Resources/TimeEntryResource.php +++ b/backend/app/Http/Resources/TimeEntryResource.php @@ -22,7 +22,7 @@ public function toArray(Request $request): array 'start_time' => $this->start_time->format('g:i a'), 'end_time' => $this->end_time->format('g:i a'), 'time_taken' => $this->time_taken, - 'create_at' => $this->created_at + 'created_at' => $this->created_at ]; diff --git a/backend/app/Http/Resources/UserResource.php b/backend/app/Http/Resources/UserResource.php new file mode 100644 index 0000000..3b6b127 --- /dev/null +++ b/backend/app/Http/Resources/UserResource.php @@ -0,0 +1,19 @@ + + */ + public function toArray(Request $request): array + { + return parent::toArray($request); + } +} diff --git a/backend/app/Models/Channel.php b/backend/app/Models/Channel.php new file mode 100644 index 0000000..5ecc49c --- /dev/null +++ b/backend/app/Models/Channel.php @@ -0,0 +1,77 @@ +belongsTo(User::class, 'user_id'); + } + + public function members(): HasMany + { + return $this->hasMany(Member::class); + } + + + public function invitations(): HasMany + { + return $this->hasMany(Invitation::class); + } + + public function sharedDays(): HasMany + { + return $this->hasMany(SharedDay::class); + } + protected function membersCount(): Attribute + { + return Attribute::make( + get: fn () => $this->members()->count() + ); + } + + public function scopeOwned(Builder $query, User $user): Builder{ + return $query->where('user_id', $user->id); + } + + public function scopeJoined(Builder $query, User $user ): Builder{ + return $query->whereHas('members', fn($q) => $q->where('user_id', $user->id)); + } + public function isOwner(User $user): bool{ + return $this->user_id === $user->id; + } + public function isMember(User $user): bool{ + return $this->members()->where('user_id', $user->id)->exists(); + } + public function isOwnerOrMember(User $user): bool{ + return $this->isOwner($user) || $this->isMember($user); + } + + + + + + + + + + + + + + + + + + +} diff --git a/backend/app/Models/Comment.php b/backend/app/Models/Comment.php new file mode 100644 index 0000000..bb31f52 --- /dev/null +++ b/backend/app/Models/Comment.php @@ -0,0 +1,27 @@ + 'datetime: M d, Y h:i A', + ]; + + public function sharedDay(): BelongsTo + { + return $this->belongsTo(SharedDay::class); + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/backend/app/Models/Invitation.php b/backend/app/Models/Invitation.php new file mode 100644 index 0000000..a278702 --- /dev/null +++ b/backend/app/Models/Invitation.php @@ -0,0 +1,54 @@ +belongsTo(Channel::class); + } + + public function invitedUser(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_id'); + } + + public function invitedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by_id'); + } + + public function logs(): HasMany + { + return $this->hasMany(InvitationLog::class); + } + + public function scopePending(Builder $query): Builder + { + return $query->where('status', 'pending'); + } + + public function scopeReceived(Builder $query, User $user): Builder + { + return $query->where('invited_id', $user->id); + } + + public function scopeForChannel(Builder $query, Channel $channel): Builder + { + return $query->where('channel_id', $channel->id); + } +} diff --git a/backend/app/Models/InvitationLog.php b/backend/app/Models/InvitationLog.php new file mode 100644 index 0000000..5664126 --- /dev/null +++ b/backend/app/Models/InvitationLog.php @@ -0,0 +1,20 @@ +belongsTo(Invitation::class); + } + public function changed_by(): BelongsTo{ + return $this->belongsTo(User::class); + } + +} diff --git a/backend/app/Models/Member.php b/backend/app/Models/Member.php new file mode 100644 index 0000000..56914ad --- /dev/null +++ b/backend/app/Models/Member.php @@ -0,0 +1,24 @@ +belongsTo(Channel::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/SharedDay.php b/backend/app/Models/SharedDay.php new file mode 100644 index 0000000..e2189a8 --- /dev/null +++ b/backend/app/Models/SharedDay.php @@ -0,0 +1,61 @@ + 'date:Y-m-d', + ]; + protected $appends = ['entries_count', 'total_hours']; + + public function channel() + { + return $this->belongsTo(Channel::class); + } + + public function sharedBy() + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function entries() + { + return $this->hasMany(SharedDayEntry::class); + } + + public function comments() + { + return $this->hasMany(Comment::class); + } + protected function totalTime(): Attribute + { + return Attribute::make( + get: function () { + $totalMinutes = $this->entries->sum(function ($entry) { + $timeTaken = $entry->timeEntry->time_taken; + return ($timeTaken['hours'] * 60) + $timeTaken['minutes']; + }); + + return [ + 'hours' => intdiv($totalMinutes, 60), + 'minutes' => $totalMinutes % 60 + ]; + } + ); + } + protected function entriesCount() : Attribute + { + return Attribute::make( + get: fn()=> $this->entries()->count() + ); + } +} diff --git a/backend/app/Models/SharedDayEntry.php b/backend/app/Models/SharedDayEntry.php new file mode 100644 index 0000000..44da113 --- /dev/null +++ b/backend/app/Models/SharedDayEntry.php @@ -0,0 +1,24 @@ +belongsTo(SharedDay::class); + } + + public function timeEntry(): BelongsTo + { + return $this->belongsTo(TimeEntry::class); + } +} diff --git a/backend/app/Models/TimeEntry.php b/backend/app/Models/TimeEntry.php index 77aede0..9ac485e 100644 --- a/backend/app/Models/TimeEntry.php +++ b/backend/app/Models/TimeEntry.php @@ -3,32 +3,30 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\{Builder, Model}; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Carbon; -use Illuminate\Database\Eloquent\Builder; class TimeEntry extends Model { use HasFactory; - + protected $fillable = [ 'user_id', 'label', 'start_time', 'end_time', ]; - + protected $casts = [ 'created_at' => 'datetime:Y-m-d', - 'start_time' => 'datetime', - 'end_time' => 'datetime' + 'start_time' => 'datetime:h:i A', + 'end_time' => 'datetime:h:i A', ]; - public function user() { return $this->belongsTo(User::class); } - + protected function timeTaken(): Attribute { return Attribute::make( @@ -51,44 +49,64 @@ public function scopeDate(Builder $query, string $date): Builder public function scopeToday(Builder $query): Builder { return $query->whereDate( - 'created_at', - today() + 'created_at', + today() ); } + public function scopeSearchByLabel(Builder $query, string $search): Builder { return $query->where('label', 'LIKE', '%' . $search . '%'); } public function scopeSort(Builder $query, string $sort): Builder { - $query->when($sort ?? null, - fn($q, $sort) => $q->orderBy('created_at', $sort), + $query->when($sort ?? null, + fn($q, $sort) => $q->orderBy('created_at', $sort), fn($q) => $q->orderBy('created_at', 'desc') ); return $query; } + public function scopeHistory(Builder $query): Builder { return $query->selectRaw('DATE(created_at) as date') - ->distinct() + ->groupBy('date') ->orderBy('date', 'desc'); - } - public function scopeSearch($query, array $filters): Builder { - $query->when($filters['date'] ?? null, - fn($q, $date) => $q->date($date), + $query->when($filters['date'] ?? null, + fn($q, $date) => $q->date($date), fn($q) => $q->today() ) - ->when($filters['sort'] ?? null, + ->when($filters['sort'] ?? null, fn($q, $sort) => $q->sort($sort) ) ->when($filters['search']?? null, fn($q,$search)=> $q->searchByLabel($search)) ; - - return $query; -}} \ No newline at end of file + + return $query; +} + + +public function scopeInRange($query, ?string $from, ?string $to): Builder +{ + $from = $from + ? Carbon::parse($from)->startOfDay() + : Carbon::parse($query->min('created_at') ?? now()->subYear())->startOfDay(); + + $to = $to + ? Carbon::parse($to)->endOfDay() + : now()->endOfDay(); + + return $query->whereBetween('created_at', [$from, $to]); +} +// new +public function sharedDayEntries() +{ + return $this->hasMany(SharedDayEntry::class); +} +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 644ad90..ce9645f 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -7,14 +7,39 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; +use Illuminate\Database\Eloquent\Builder; class User extends Authenticatable { use HasApiTokens, HasFactory, Notifiable; - public function time_entries(){ + public function time_entries(){ return $this->hasMany(TimeEntry::class); - } + } + public function channels() + { + return $this->hasMany(Channel::class); + } + + public function memberships() + { + return $this->hasMany(Member::class); + } + + public function invitations() + { + return $this->hasMany(Invitation::class); + } + + public function sharedDays() + { + return $this->hasMany(SharedDay::class); + } + + public function comments() + { + return $this->hasMany(Comment::class); + } /** * The attributes that are mass assignable. @@ -46,4 +71,10 @@ public function time_entries(){ 'email_verified_at' => 'datetime', 'password' => 'hashed', ]; + + public function scopeSearchByNameOrEmail(Builder $query, string $search): ?Builder { + return $query->where('name', 'LIKE', '%' . $search . '%') + ->orWhere('email', 'LIKE', '%' . $search . '%') + ; + } } diff --git a/backend/app/Observers/InvitationObserver.php b/backend/app/Observers/InvitationObserver.php new file mode 100644 index 0000000..37d7d8a --- /dev/null +++ b/backend/app/Observers/InvitationObserver.php @@ -0,0 +1,38 @@ + $invitation->id, + 'user_id' => $invitation->invited_by_id, + 'action' => 'sent', + ]); + } + + public function updated(Invitation $invitation): void + { + if (! $invitation->wasChanged('status')) { + return; + } + + if (! in_array($invitation->status, ['accepted', 'declined', 'cancelled'], true)) { + return; + } + + InvitationLog::create([ + 'invitation_id' => $invitation->id, + 'user_id' => in_array($invitation->status, ['accepted', 'declined'], true) + ? $invitation->invited_id + : $invitation->invited_by_id, + 'action' => $invitation->status, + ]); + } +} diff --git a/backend/app/Observers/TimeEntryObserver.php b/backend/app/Observers/TimeEntryObserver.php new file mode 100644 index 0000000..e73bc0e --- /dev/null +++ b/backend/app/Observers/TimeEntryObserver.php @@ -0,0 +1,18 @@ +duration_minutes = $entry->start_time->diffInMinutes($entry->end_time); + } + + public function updating(TimeEntry $entry): void + { + $entry->duration_minutes = $entry->start_time->diffInMinutes($entry->end_time); + } +} diff --git a/backend/app/Policies/ChannelPolicy.php b/backend/app/Policies/ChannelPolicy.php new file mode 100644 index 0000000..565b170 --- /dev/null +++ b/backend/app/Policies/ChannelPolicy.php @@ -0,0 +1,34 @@ +isOwnerOrMember($user); + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, Channel $channel): bool + { + return $channel->isOwner($user); + } + + public function delete(User $user, Channel $channel): bool + { + return $channel->isOwner($user); + } + + +} diff --git a/backend/app/Policies/CommentPolicy.php b/backend/app/Policies/CommentPolicy.php new file mode 100644 index 0000000..20e4742 --- /dev/null +++ b/backend/app/Policies/CommentPolicy.php @@ -0,0 +1,28 @@ +isOwnerOrMember($user); + } + + public function create(User $user, Channel $channel): bool + { + return $channel->isOwnerOrMember($user); + } + + public function update(User $user, Comment $comment): bool + { + return $comment->user_id === $user->id; + } + + public function delete(User $user, Comment $comment, Channel $channel): bool + { + return $comment->user_id === $user->id || $channel->isOwner($user); + } +} \ No newline at end of file diff --git a/backend/app/Policies/InvitationPolicy.php b/backend/app/Policies/InvitationPolicy.php new file mode 100644 index 0000000..8f9b6e4 --- /dev/null +++ b/backend/app/Policies/InvitationPolicy.php @@ -0,0 +1,51 @@ +isOwner($user); + } + public function create(User $user, Channel $channel): bool + { + return $channel->isOwner($user); + } + + public function accept(User $user, Invitation $invitation): Response + { + if ($invitation->status !== 'pending') { + return Response::deny('This invitation is no longer pending.'); + } + + return $user->id === $invitation->invited_id + ? Response::allow() + : Response::deny('You are not the invited user for this invitation.'); + } + + public function decline(User $user, Invitation $invitation): Response + { + if ($invitation->status !== 'pending') { + return Response::deny('This invitation is no longer pending.'); + } + + return $user->id === $invitation->invited_id + ? Response::allow() + : Response::deny('You are not the invited user for this invitation.'); + } + + public function cancel(User $user, Invitation $invitation): Response + { + if ($invitation->status !== 'pending') { + return Response::deny('Only pending invitations can be cancelled.'); + } + + return $user->id === $invitation->invited_by_id + ? Response::allow() + : Response::deny('You are not allowed to cancel this invitation.'); + } +} diff --git a/backend/app/Policies/SharedDayPolicy.php b/backend/app/Policies/SharedDayPolicy.php new file mode 100644 index 0000000..c209967 --- /dev/null +++ b/backend/app/Policies/SharedDayPolicy.php @@ -0,0 +1,32 @@ +isOwnerOrMember($user); + } + + public function view(User $user, Channel $channel): bool + { + return $channel->isOwnerOrMember($user); + } + public function create(User $user, Channel $channel): bool + { + return $channel->isOwner($user) ; + } + + public function delete(User $user, SharedDay $sharedDay): bool + { + return $user->id === $sharedDay->user_id; + } + + +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 452e6b6..c4362b2 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -3,7 +3,10 @@ namespace App\Providers; use Illuminate\Support\ServiceProvider; - +use App\Models\Invitation; +use App\Models\TimeEntry; +use App\Observers\InvitationObserver; +use App\Observers\TimeEntryObserver; class AppServiceProvider extends ServiceProvider { /** @@ -19,6 +22,7 @@ public function register(): void */ public function boot(): void { - // + TimeEntry::observe(TimeEntryObserver::class); + Invitation::observe(InvitationObserver::class); } } diff --git a/backend/app/Services/InvitationStatusService.php b/backend/app/Services/InvitationStatusService.php new file mode 100644 index 0000000..54114a2 --- /dev/null +++ b/backend/app/Services/InvitationStatusService.php @@ -0,0 +1,63 @@ + $this->accept($invitation, $actor), + 'declined' => $this->decline($invitation, $actor), + 'cancelled' => $this->cancel($invitation, $actor), + default => throw new InvalidArgumentException('Invalid status value.'), + }; + } + + public function accept(Invitation $invitation, User $actor): void + { + if ($invitation->status !== 'pending') { + throw new DomainException('Invitation is not pending.'); + } + + DB::transaction(function () use ($invitation, $actor) { + $invitation->update([ + 'status' => 'accepted', + ]); + + Member::firstOrCreate([ + 'user_id' => $actor->id, + 'channel_id' => $invitation->channel_id, + ]); + }); + } + + public function decline(Invitation $invitation, User $actor): void + { + if ($invitation->status !== 'pending') { + throw new DomainException('Invitation is not pending.'); + } + + $invitation->update([ + 'status' => 'declined', + ]); + } + + public function cancel(Invitation $invitation, User $actor): void + { + if ($invitation->status !== 'pending') { + throw new DomainException('Invitation is not pending.'); + } + + $invitation->update([ + 'status' => 'cancelled', + ]); + } +} diff --git a/backend/app/Services/InviteService.php b/backend/app/Services/InviteService.php new file mode 100644 index 0000000..712fb39 --- /dev/null +++ b/backend/app/Services/InviteService.php @@ -0,0 +1,81 @@ +findUser($identifier); + + if (! $user) { + throw ValidationException::withMessages([ + 'identifier' => ['User not found for the given identifier.'], + ]); + } + + $this->ensureNotOwner($user, $channel); + $this->ensureNotMember($user, $channel); + + return $this->updateOrCreate($user, $channel); + } + + private function findUser(string $identifier): ?User + { + return User::query() + ->where('email', $identifier) + ->orWhere('name', $identifier) + ->first(); + } + + private function ensureNotOwner(User $user, Channel $channel): void + { + if ($channel->user_id === $user->id) { + throw ValidationException::withMessages([ + 'identifier' => ['You cannot invite the channel owner.'], + ]); + } + } + + private function ensureNotMember(User $user, Channel $channel): void + { + if ($channel->members()->where('user_id', $user->id)->exists()) { + throw ValidationException::withMessages([ + 'identifier' => ['This user is already a member of this channel.'], + ]); + } + } + + private function updateOrCreate(User $user, Channel $channel): Invitation + { + $existing = Invitation::where('channel_id', $channel->id) + ->where('invited_id', $user->id) + ->first(); + + if ($existing && in_array($existing->status, ['declined', 'cancelled'], true)) { + $existing->update([ + 'status' => 'pending', + ]); + + return $existing->refresh(); + } + + if ($existing) { + throw ValidationException::withMessages([ + 'identifier' => ['User already has a pending invitation to this channel.'], + ]); + } + + return Invitation::create([ + 'channel_id' => $channel->id, + 'invited_id' => $user->id, + 'invited_by_id' => $channel->user_id, + 'status' => 'pending', + ]); + } +} diff --git a/backend/app/Services/ReportService.php b/backend/app/Services/ReportService.php new file mode 100644 index 0000000..cdf9314 --- /dev/null +++ b/backend/app/Services/ReportService.php @@ -0,0 +1,138 @@ +addHours(24), function () use($userId, $from, $to) { + return [ + 'quick_stats' => $this->quickStats($userId, $from, $to), + 'total_time_by_label' => $this->totalTimeByLabel($userId, $from, $to), + 'most_used_labels' => $this->mostUsedLabels($userId, $from, $to), + 'avg_time_per_label' => $this->avgTimePerLabel($userId, $from, $to), + 'avg_labels_per_day' => $this->avgLabelsPerDay($userId, $from, $to), + ]; + + }); + + } + + private function quickStats(int $userId, ?string $from, ?string $to): array { + $stats = TimeEntry::where('user_id', $userId) + ->inRange($from, $to) + ->selectRaw('count(*) as total_entries, + SUM(duration_minutes) as total_minutes, + COUNT(DISTINCT DATE(created_at)) as total_days + ')->first(); + $totalMinutes = $stats->total_minutes ?? 0; + $totalDays = $stats->total_days ?? 1; + $avgMinutesPerDay = $totalDays > 0 ? intdiv($totalMinutes, $totalDays) : 0; + return [ + "total_entries" => $stats->total_entries ?? 0, + "total_hours" => intDiv($totalMinutes,60) , + "total_minutes" => $totalMinutes % 60, + "total_days" => $totalDays, + "avg_hours_per_day" => intdiv($avgMinutesPerDay, 60), + "avg_minutes_per_day" => $avgMinutesPerDay % 60, + ]; + + } + + + private function totalTimeByLabel(int $userId, ?string $from, ?string $to): array + { + $rows = TimeEntry::where('user_id', $userId) + ->inRange($from, $to) + ->selectRaw('label, SUM(duration_minutes) as total_minutes') + ->groupBy('label') + ->orderByDesc('total_minutes') + ->get(); + + return $rows->map(fn($row) => [ + 'label' => $row->label, + 'hours' => intdiv($row->total_minutes, 60), + 'minutes' => $row->total_minutes % 60, + ])->toArray(); + } + + + private function mostUsedLabels(int $userId, ?string $from, ?string $to): array + { + $totalMinutes = TimeEntry::where('user_id', $userId) + ->inRange($from, $to) + ->sum('duration_minutes'); + + $rows = TimeEntry::where('user_id', $userId) + ->inRange($from, $to) + ->selectRaw('label, SUM(duration_minutes) as total_minutes') + ->groupBy('label') + ->orderByDesc('total_minutes') + ->get(); + + $top5 = $rows->take(5); + $others = $rows->skip(5); + + $result = $top5->map(fn($row) => [ + 'label' => $row->label, + 'hours' => intdiv($row->total_minutes, 60), + 'minutes' => $row->total_minutes % 60, + 'percentage' => $totalMinutes > 0 + ? round(($row->total_minutes / $totalMinutes) * 100, 1) + : 0 + ])->toArray(); + + if ($others->isNotEmpty()) { + $othersMinutes = $others->sum('total_minutes'); + $result[] = [ + 'label' => 'Other', + 'hours' => intdiv($othersMinutes, 60), + 'minutes' => $othersMinutes % 60, + 'percentage' => $totalMinutes > 0 + ? round(($othersMinutes / $totalMinutes) * 100, 1) + : 0 + ]; + } + + return $result; + } + + private function avgTimePerLabel(int $userId, ?string $from, ?string $to): array + { + $rows = TimeEntry::where('user_id', $userId) + ->inRange($from, $to) + ->selectRaw('label, AVG(duration_minutes) as avg_minutes') + ->groupBy('label') + ->orderByDesc('avg_minutes') + ->get(); + + return $rows->map(fn($row) => [ + 'label' => $row->label, + 'hours' => intdiv((int) $row->avg_minutes, 60), + 'minutes' => (int) $row->avg_minutes % 60, + ])->toArray(); + } + + private function avgLabelsPerDay(int $userId, ?string $from, ?string $to): float + { + $rows = TimeEntry::where('user_id', $userId) + ->inRange($from, $to) + ->selectRaw('DATE(created_at) as date, COUNT(DISTINCT label) as label_count') + ->groupByRaw('DATE(created_at)') + ->get(); + + if ($rows->isEmpty()) return 0; + + return round($rows->avg('label_count'), 1); + } + + +} diff --git a/backend/composer.json b/backend/composer.json index 8a3d72d..4635cfa 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -9,7 +9,8 @@ "guzzlehttp/guzzle": "^7.2", "laravel/framework": "^10.10", "laravel/sanctum": "^3.3", - "laravel/tinker": "^2.8" + "laravel/tinker": "^2.8", + "predis/predis": "^3.4" }, "require-dev": { "fakerphp/faker": "^1.9.1", diff --git a/backend/composer.lock b/backend/composer.lock index 26d65e5..3663f88 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9c491b8531eec05ba41a11d9276a5749", + "content-hash": "5b11a009a476433c1a892288c53efea3", "packages": [ { "name": "brick/math", @@ -2470,6 +2470,69 @@ ], "time": "2025-08-21T11:53:16+00:00" }, + { + "name": "predis/predis", + "version": "v3.4.2", + "source": { + "type": "git", + "url": "https://github.com/predis/predis.git", + "reference": "2033429520d8997a7815a2485f56abe6d2d0e075" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/predis/predis/zipball/2033429520d8997a7815a2485f56abe6d2d0e075", + "reference": "2033429520d8997a7815a2485f56abe6d2d0e075", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.0|^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3", + "phpstan/phpstan": "^1.9", + "phpunit/phpcov": "^6.0 || ^8.0", + "phpunit/phpunit": "^8.0 || ~9.4.4" + }, + "suggest": { + "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" + }, + "type": "library", + "autoload": { + "psr-4": { + "Predis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Till Krüss", + "homepage": "https://till.im", + "role": "Maintainer" + } + ], + "description": "A flexible and feature-complete Redis/Valkey client for PHP.", + "homepage": "http://github.com/predis/predis", + "keywords": [ + "nosql", + "predis", + "redis" + ], + "support": { + "issues": "https://github.com/predis/predis/issues", + "source": "https://github.com/predis/predis/tree/v3.4.2" + }, + "funding": [ + { + "url": "https://github.com/sponsors/tillkruss", + "type": "github" + } + ], + "time": "2026-03-09T20:33:04+00:00" + }, { "name": "psr/clock", "version": "1.0.0", diff --git a/backend/database/factories/ChannelFactory.php b/backend/database/factories/ChannelFactory.php new file mode 100644 index 0000000..a96176c --- /dev/null +++ b/backend/database/factories/ChannelFactory.php @@ -0,0 +1,24 @@ + + */ +class ChannelFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + "name" => fake()->name, + "description" => fake()->sentence, + ]; + } +} diff --git a/backend/database/factories/TimeEntryFactory.php b/backend/database/factories/TimeEntryFactory.php index f9cc77a..258eaa6 100644 --- a/backend/database/factories/TimeEntryFactory.php +++ b/backend/database/factories/TimeEntryFactory.php @@ -21,6 +21,7 @@ public function definition(): array "start_time" => fake()->time(), "end_time" => fake()->time(), 'created_at' => fake()->dateTimeBetween('-5 days'), + // ]; } diff --git a/backend/database/migrations/2026_02_27_201248_create_channels_table.php b/backend/database/migrations/2026_02_27_201248_create_channels_table.php new file mode 100644 index 0000000..6a907db --- /dev/null +++ b/backend/database/migrations/2026_02_27_201248_create_channels_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignIdFor(User::class); + $table->string('name'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('channels'); + } +}; diff --git a/backend/database/migrations/2026_02_27_201308_create_members_table.php b/backend/database/migrations/2026_02_27_201308_create_members_table.php new file mode 100644 index 0000000..ae96f2f --- /dev/null +++ b/backend/database/migrations/2026_02_27_201308_create_members_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignIdFor(User::class); + $table->foreignIdFor(Channel::class)->constrained()->cascadeOnDelete(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('members'); + } +}; diff --git a/backend/database/migrations/2026_02_27_201329_create_invitations_table.php b/backend/database/migrations/2026_02_27_201329_create_invitations_table.php new file mode 100644 index 0000000..db83faa --- /dev/null +++ b/backend/database/migrations/2026_02_27_201329_create_invitations_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignIdFor(Channel::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(User::class, 'invited_id')->constrained('users')->cascadeOnDelete(); + $table->foreignIdFor(User::class, 'invited_by_id')->constrained('users')->cascadeOnDelete(); + $table->enum('status', ['pending', 'accepted', 'declined', 'cancelled'])->default('pending'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('invitations'); + } +}; diff --git a/backend/database/migrations/2026_02_27_201625_create_shared_days_table.php b/backend/database/migrations/2026_02_27_201625_create_shared_days_table.php new file mode 100644 index 0000000..6861ff5 --- /dev/null +++ b/backend/database/migrations/2026_02_27_201625_create_shared_days_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignIdFor(Channel::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(User::class); + $table->date('date'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shared_days'); + } +}; diff --git a/backend/database/migrations/2026_02_27_201637_create_shared_day_entries_table.php b/backend/database/migrations/2026_02_27_201637_create_shared_day_entries_table.php new file mode 100644 index 0000000..5256543 --- /dev/null +++ b/backend/database/migrations/2026_02_27_201637_create_shared_day_entries_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignIdFor(SharedDay::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(TimeEntry::class)->constrained()->cascadeOnDelete(); + $table->unique(['shared_day_id', 'time_entry_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shared_day_entries'); + } +}; diff --git a/backend/database/migrations/2026_02_27_201648_create_comments_table.php b/backend/database/migrations/2026_02_27_201648_create_comments_table.php new file mode 100644 index 0000000..6af3dd6 --- /dev/null +++ b/backend/database/migrations/2026_02_27_201648_create_comments_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignIdFor(SharedDay::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(User::class); + $table->text('body'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('comments'); + } +}; diff --git a/backend/database/migrations/2026_03_01_232740_create_invitation_logs_table.php b/backend/database/migrations/2026_03_01_232740_create_invitation_logs_table.php new file mode 100644 index 0000000..79d81a0 --- /dev/null +++ b/backend/database/migrations/2026_03_01_232740_create_invitation_logs_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignIdFor(Invitation::class); + $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); + $table->enum('action', ['sent', 'accepted', 'declined', 'cancelled'])->default('sent'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('invitation_logs'); + } +}; diff --git a/backend/database/migrations/2026_03_11_212218_add_unique_constraint_to_shared_days_table.php b/backend/database/migrations/2026_03_11_212218_add_unique_constraint_to_shared_days_table.php new file mode 100644 index 0000000..7818ff6 --- /dev/null +++ b/backend/database/migrations/2026_03_11_212218_add_unique_constraint_to_shared_days_table.php @@ -0,0 +1,25 @@ +unique(['channel_id', 'user_id', 'date']); + }); + } + + public function down(): void + { + Schema::table('shared_days', function (Blueprint $table) { + $table->dropUnique(['channel_id', 'user_id', 'date']); + }); + } +}; diff --git a/backend/database/migrations/2026_03_20_200524_add_duration_minutes_to_time_entries_table.php b/backend/database/migrations/2026_03_20_200524_add_duration_minutes_to_time_entries_table.php new file mode 100644 index 0000000..813affb --- /dev/null +++ b/backend/database/migrations/2026_03_20_200524_add_duration_minutes_to_time_entries_table.php @@ -0,0 +1,25 @@ +unsignedInteger('duration_minutes')->default(0); + }); + } + + public function down(): void + { + Schema::table('time_entries', function (Blueprint $table) { + $table->dropColumn('duration_minutes'); + }); + } +}; diff --git a/backend/database/seeders/ChannelSeeder.php b/backend/database/seeders/ChannelSeeder.php new file mode 100644 index 0000000..0f4d900 --- /dev/null +++ b/backend/database/seeders/ChannelSeeder.php @@ -0,0 +1,21 @@ +create([ + 'user_id' => fn() => $users->random()->id + ]); +} +} diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index dc4eb0d..144a15b 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -3,8 +3,7 @@ namespace Database\Seeders; use Illuminate\Database\Seeder; -use App\Models\User; -use App\Models\TimeEntry; +use App\Models\{TimeEntry, User}; class DatabaseSeeder extends Seeder { @@ -15,5 +14,9 @@ public function run(): void { $this->call(UserSeeder::class); $this->call(TimeEntrySeeder::class); + $this->call(ChannelSeeder::class); + $this->call(MemberSeeder::class); + $this->call(InvitationSeeder::class); + } -} \ No newline at end of file +} diff --git a/backend/database/seeders/InvitationSeeder.php b/backend/database/seeders/InvitationSeeder.php new file mode 100644 index 0000000..7d8ad43 --- /dev/null +++ b/backend/database/seeders/InvitationSeeder.php @@ -0,0 +1,58 @@ +each(function (Channel $channel) { + // Pick some users that are not the channel owner + $users = User::where('id', '!=', $channel->user_id) + ->inRandomOrder() + ->take(3) + ->get(); + + $users->each(function (User $user) use ($channel) { + $invitation = Invitation::where('channel_id', $channel->id) + ->where('invited_id', $user->id) + ->first(); + + if ($invitation) { + // If already a member (accepted), do nothing + if ($invitation->status === 'accepted') { + return; + } + + // If declined or cancelled (or any non-pending, non-accepted future state), + // reset back to pending instead of creating a new row. + if ($invitation->status !== 'pending') { + $invitation->update([ + 'status' => 'pending', + ]); + } + + return; + } + + // No existing invitation: create a new pending one + Invitation::create([ + 'channel_id' => $channel->id, + 'invited_id' => $user->id, + 'invited_by_id' => $channel->user_id, + 'status' => 'pending', + ]); + }); + }); + } +} diff --git a/backend/database/seeders/MemberSeeder.php b/backend/database/seeders/MemberSeeder.php new file mode 100644 index 0000000..cc14149 --- /dev/null +++ b/backend/database/seeders/MemberSeeder.php @@ -0,0 +1,32 @@ +each(function($channel) { + $users = User::where('id', '!=', $channel->user_id) + ->inRandomOrder() + ->take(3) + ->get(); + + $users->each(function($user) use ($channel) { + Member::firstOrCreate([ + 'user_id' => $user->id, + 'channel_id' => $channel->id + ]); + }); + }); +} +} diff --git a/backend/routes/api.php b/backend/routes/api.php index 304d0cb..1815405 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,40 +1,48 @@ get('/user', function (Request $request) { return $request->user(); }); - - Route::middleware('guest')->controller(AuthController::class)->group(function () { - Route::post('/register', 'store'); + Route::post('/register', 'store'); Route::post('/login', 'login'); }); - Route::middleware('auth:sanctum')->group(function () { Route::post('/logout', [AuthController::class, 'logout']); - Route::apiResource('time-entries', TimeEntryController::class); -}); - + Route::apiResource('users', UserController::class)->only(['index']); + Route::apiResource('channels', ChannelController::class); - + Route::apiResource('time-entries', TimeEntryController::class); - + Route::apiResource('channels.invitations', InvitationController::class)->scoped(); + + Route::post('shared-days', [SharedDayController::class, 'store']); + Route::apiResource('channels.shared-days', SharedDayController::class) + ->only(['index', 'destroy', 'show']); + Route::apiResource('channels.shared-days.comments', CommentController::class) + ->only(['index', 'update', 'destroy', 'store']); + + Route::get('reports',[ReportController::class,'index']); + Route::prefix('me')->group(function () { + Route::get('shared-days', [MeController::class, 'sharedDays']); + Route::get('invitations', [MeController::class, 'invitations']); + Route::get('channels', [MeController::class, 'channels']); + Route::get('memberships', [MeController::class, 'memberships']); + }); +}); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2002f15..fde9cb9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,39 +1,93 @@ -```vue + + \ No newline at end of file diff --git a/frontend/src/components/BarChart.vue b/frontend/src/components/BarChart.vue deleted file mode 100644 index 32a16b9..0000000 --- a/frontend/src/components/BarChart.vue +++ /dev/null @@ -1,53 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/components/ListView.vue b/frontend/src/components/ListView.vue deleted file mode 100644 index 8c0a167..0000000 --- a/frontend/src/components/ListView.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/components/NavBar.vue b/frontend/src/components/NavBar.vue index cac8647..0972f15 100644 --- a/frontend/src/components/NavBar.vue +++ b/frontend/src/components/NavBar.vue @@ -1,40 +1,71 @@ - - \ No newline at end of file + await authStore.logout() +} + + + \ No newline at end of file diff --git a/frontend/src/components/channel/ChannelCard.vue b/frontend/src/components/channel/ChannelCard.vue new file mode 100644 index 0000000..9beb9bc --- /dev/null +++ b/frontend/src/components/channel/ChannelCard.vue @@ -0,0 +1,48 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/channel/ChannelOverview.vue b/frontend/src/components/channel/ChannelOverview.vue new file mode 100644 index 0000000..9a4470d --- /dev/null +++ b/frontend/src/components/channel/ChannelOverview.vue @@ -0,0 +1,65 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/channel/ChannelSettings.vue b/frontend/src/components/channel/ChannelSettings.vue new file mode 100644 index 0000000..9c1fa7f --- /dev/null +++ b/frontend/src/components/channel/ChannelSettings.vue @@ -0,0 +1,57 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/channel/members/ChannelMembers.vue b/frontend/src/components/channel/members/ChannelMembers.vue new file mode 100644 index 0000000..87c718a --- /dev/null +++ b/frontend/src/components/channel/members/ChannelMembers.vue @@ -0,0 +1,99 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/channel/members/InviteMemberModal.vue b/frontend/src/components/channel/members/InviteMemberModal.vue new file mode 100644 index 0000000..eaaf3ef --- /dev/null +++ b/frontend/src/components/channel/members/InviteMemberModal.vue @@ -0,0 +1,117 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/channel/members/PendingInvitationsList.vue b/frontend/src/components/channel/members/PendingInvitationsList.vue new file mode 100644 index 0000000..022530e --- /dev/null +++ b/frontend/src/components/channel/members/PendingInvitationsList.vue @@ -0,0 +1,28 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/channel/shared-days/AddComment.vue b/frontend/src/components/channel/shared-days/AddComment.vue new file mode 100644 index 0000000..3f8a73c --- /dev/null +++ b/frontend/src/components/channel/shared-days/AddComment.vue @@ -0,0 +1,49 @@ + + \ No newline at end of file diff --git a/frontend/src/components/channel/shared-days/CommentsSection.vue b/frontend/src/components/channel/shared-days/CommentsSection.vue new file mode 100644 index 0000000..3e3948a --- /dev/null +++ b/frontend/src/components/channel/shared-days/CommentsSection.vue @@ -0,0 +1,77 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/channel/shared-days/SharedDayCard.vue b/frontend/src/components/channel/shared-days/SharedDayCard.vue new file mode 100644 index 0000000..ead198c --- /dev/null +++ b/frontend/src/components/channel/shared-days/SharedDayCard.vue @@ -0,0 +1,103 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/channel/shared-days/SharedDayDetails.vue b/frontend/src/components/channel/shared-days/SharedDayDetails.vue new file mode 100644 index 0000000..eead234 --- /dev/null +++ b/frontend/src/components/channel/shared-days/SharedDayDetails.vue @@ -0,0 +1,90 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/charts/BarChart.vue b/frontend/src/components/charts/BarChart.vue new file mode 100644 index 0000000..e0de931 --- /dev/null +++ b/frontend/src/components/charts/BarChart.vue @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/charts/DoughnutChart.vue b/frontend/src/components/charts/DoughnutChart.vue new file mode 100644 index 0000000..2d98938 --- /dev/null +++ b/frontend/src/components/charts/DoughnutChart.vue @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/charts/HorizontalBarChart.vue b/frontend/src/components/charts/HorizontalBarChart.vue new file mode 100644 index 0000000..fcd3d53 --- /dev/null +++ b/frontend/src/components/charts/HorizontalBarChart.vue @@ -0,0 +1,24 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/history/HistoryDateItem.vue b/frontend/src/components/history/HistoryDateItem.vue new file mode 100644 index 0000000..c28a075 --- /dev/null +++ b/frontend/src/components/history/HistoryDateItem.vue @@ -0,0 +1,21 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/history/HistorySidebar.vue b/frontend/src/components/history/HistorySidebar.vue new file mode 100644 index 0000000..a28833f --- /dev/null +++ b/frontend/src/components/history/HistorySidebar.vue @@ -0,0 +1,99 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/invitations/InvitationCard.vue b/frontend/src/components/invitations/InvitationCard.vue new file mode 100644 index 0000000..95fee2b --- /dev/null +++ b/frontend/src/components/invitations/InvitationCard.vue @@ -0,0 +1,87 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/time-entries/ShareDayModal.vue b/frontend/src/components/time-entries/ShareDayModal.vue new file mode 100644 index 0000000..c857e90 --- /dev/null +++ b/frontend/src/components/time-entries/ShareDayModal.vue @@ -0,0 +1,196 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/time-entries/SingleEntry.vue b/frontend/src/components/time-entries/SingleEntry.vue new file mode 100644 index 0000000..1e4aee3 --- /dev/null +++ b/frontend/src/components/time-entries/SingleEntry.vue @@ -0,0 +1,129 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/time-entries/TimeEntryList.vue b/frontend/src/components/time-entries/TimeEntryList.vue new file mode 100644 index 0000000..9d272b1 --- /dev/null +++ b/frontend/src/components/time-entries/TimeEntryList.vue @@ -0,0 +1,29 @@ + + + \ No newline at end of file diff --git a/frontend/src/composables/usePaginate.js b/frontend/src/composables/usePaginate.js new file mode 100644 index 0000000..bf7f74a --- /dev/null +++ b/frontend/src/composables/usePaginate.js @@ -0,0 +1,18 @@ +import {ref, computed} from 'vue'; +const usePagination = (fetchFn)=>{ + const items = ref([]) + const currentPage = ref(1); + const lastPage = ref(1); + const loading = ref(false) + const error = ref(null) + const hasMore = computed(() => currentPage.value < lastPage.value) + +} + + +const loadMore = ()=>{ + currentPage+=1; + fetchFn(); + comments.value = [...comments.value, ...data] +} +const hasMore = computed(() => currentPage.value < lastPage.value) \ No newline at end of file diff --git a/frontend/src/locales/ar.json b/frontend/src/locales/ar.json new file mode 100644 index 0000000..3f6a7d8 --- /dev/null +++ b/frontend/src/locales/ar.json @@ -0,0 +1,227 @@ +{ + "message": { + "hello": "مرحبا", + "hide_history": "اخفاء", + "add_entry": "أضف نشاط جديد" + }, + "login": { + "welcome_back": "أهلا بعودتك", + "email": "الايميل", + "password": "كلمة المرور", + "login": "تسجيل الدخول", + "dont_have_account": "لا تملك حساب ؟", + "sign_up_here": "أنشئ حساب هنا" + }, + "register": { + "create_account": "أنشئ حساب", + "name": "الاسم", + "email": "الايميل", + "password": "كلمة المرور", + "confirm_password": "تأكيد كلمة المرور", + "sign_up": "أنشئ حساب", + "you_already_have_account": "لديك حساب؟", + "login": "سجل الدخول" + }, + "about": { + "title": "عن الموقع", + "p1": "تايم تراكر هو أداة إنتاجية شخصية تساعدك على تسجيل وفهم كيفية قضاء وقتك كل يوم. فقط أضف نشاطاً بعنوان ووقت بداية ونهاية — وسيتولى التطبيق الباقي.", + "p2": "يمكنك مشاركة سجلات نشاطك اليومية مع القنوات — وهي مجموعات من الأشخاص الذين تتعاون معهم. يمكن لأعضاء القناة عرض أيامك المشتركة ونشاطاتك وترك تعليقات. إنها طريقة بسيطة للبقاء شفافاً مع فريقك.", + "p3": "تمنحك ميزة التقارير صورة واضحة عن أين يذهب وقتك. اختر أي نطاق زمني وشاهد فوراً تحليلاً مفصلاً حسب النشاط ومتوسطات يومية — كل ذلك مرئي بمخططات بيانية لتسهيل اكتشاف الأنماط." + }, + "nav": { + "home": "الصفحة الرئيسية", + "about": "عن الموقع", + "add": "أضف نشاط", + "sign_out": "تسجيل الخروج", + "channels": "القنوات", + "shared_days": "الأيام المشتركة", + "reports": "التقارير", + "invitations": "الدعوات" + }, + "addForm": { + "label": "العنوان", + "start_time": "وقت البدء", + "end_time": "وقت الانتهاء", + "add_entry": "أضف النشاط" + }, + "home": { + "history": "السجل", + "hide": "إخفاء", + "add_new_entry": "أضف نشاط جديد", + "today_entries": "نشاطات اليوم", + "entries_of": "نشاطات", + "error": "عذراً! حدث خطأ", + "total_time": "إجمالي الوقت", + "total_entries": "النشاطات", + "avg_per_entry": "المتوسط لكل نشاط", + "load_more": "تحميل المزيد", + "loading": "جاري التحميل...", + "error_loading_dates": "خطأ في تحميل التواريخ", + "no_dates": "لا توجد تواريخ متاحة", + "search_placeholder": "بحث...", + "sort_default": "افتراضي", + "sort_oldest": "الأقدم", + "sort_latest": "الأحدث" + }, + "single_entry": { + "hour": "صفر ساعات | {n} ساعة | {n} ساعات", + "minute": "صفر دقائق | {n} دقيقة | {n} دقائق", + "confirm_delete": "هل أنت متأكد؟", + "yes": "نعم", + "no": "لا" + }, +"update": { + "label": "العنوان", + "start_time": "وقت البدء", + "end_time": "وقت الانتهاء", + "loading": "جاري التحديث...", + "update": "تحديث", + "save_changes": "حفظ التغييرات", + "cancel": "إلغاء" + }, + "channel_list": { + "title": "القنوات", + "new_channel": "قناة جديدة", + "my_channels": "قنواتي", + "member_of": "عضو في", + "no_owned": "ليس لديك أي قنوات بعد.", + "no_joined": "لست عضواً في أي قناة بعد.", + "loading": "جاري التحميل...", + "error": "حدث خطأ ما", + "delete_confirm": "هل أنت متأكد أنك تريد حذف هذه القناة؟", + "create_first": "أنشئ قناتك الأولى" + }, + "create_channel": { + "title": "إنشاء قناة", + "name": "الاسم", + "description": "الوصف", + "submit": "إنشاء القناة", + "loading": "جاري التحميل..." + }, + "channel": { + "overview": "نظرة عامة", + "members": "الأعضاء", + "shared_days": "الأيام المشتركة", + "settings": "الإعدادات", + "coming_soon": "قريباً...", + "delete_channel": "حذف القناة", + "delete_warning": "هذا الإجراء دائم ولا يمكن التراجع عنه.", + "delete_confirm": "هل أنت متأكد أنك تريد حذف هذه القناة؟", + "yes": "نعم، احذف", + "no": "إلغاء", + "loading": "جاري التحميل...", + "error": "حدث خطأ ما" + }, + "channel_members": { + "community": "المجتمع", + "manage_members": "إدارة أعضاء القناة والدعوات الصادرة.", + "active_members": "الأعضاء النشطون", + "pending_invitations": "الدعوات المعلقة", + "invite_member": "دعوة عضو", + "no_pending": "لا توجد دعوات معلقة." + }, + "invite_modal": { + "title": "دعوة إلى القناة", + "search_placeholder": "ابحث بالاسم أو البريد الإلكتروني", + "cancel": "إلغاء", + "send": "إرسال الدعوة", + "sending": "جاري الإرسال..." + }, + "channel_overview": { + "created": "تم الإنشاء", + "members": "الأعضاء", + "no_description": "لا يوجد وصف لهذه القناة." + }, + "channel_card": { + "no_description": "لا يوجد وصف لهذه القناة.", + "members": "أعضاء", + "enter": "دخول" + }, + "invitation_card": { + "cancel": "إلغاء", + "accept": "قبول", + "decline": "رفض" + }, + "invitations_view": { + "title": "دعواتي", + "empty": "لم تتلقَ أي دعوات بعد.", + "loading": "جاري التحميل...", + "error": "حدث خطأ ما" + }, + "share_day": { + "title": "مشاركة النشاط", + "entries_title": "اختر النشاطات التي تريد مشاركتها", + "channels_title": "شارك إلى القنوات", + "loading_entries": "جاري تحميل النشاطات...", + "loading_channels": "جاري تحميل القنوات...", + "error_entries": "فشل تحميل النشاطات. يرجى المحاولة مجدداً.", + "error_channels": "فشل تحميل القنوات. يرجى المحاولة مجدداً.", + "no_entries": "لا توجد نشاطات لهذا اليوم.", + "no_channels": "لا تمتلك أي قنوات بعد.", + "members": "أعضاء", + "sharing": "جاري المشاركة...", + "submit": "شارك اليوم", + "selected": "محدد", + "ready": "جاهز للمشاركة؟", + "summary": "{entries} نشاط إلى {channels} قنوات" + }, + "shared_day_card": { + "day": "يوم", + "entries": "نشاطات" + }, + "my_shared_days": { + "title": "أيامي المشتركة", + "shared_to": "تمت المشاركة مع {count} قناة | تمت المشاركة مع {count} قنوات", + "empty_title": "لا توجد أيام مشتركة بعد", + "empty_message": "اذهب إلى سجلك وشارك يوماً مع قناة." + }, + "channel_shared_days": { + "removed": "تمت إزالة اليوم بنجاح!", + "empty_title": "لا توجد أيام مشتركة بعد", + "empty_message": "ستظهر السجلات المشتركة هنا بمجرد نشرها." + }, + "shared_day_view": { + "summary": "ملخص سجل النشاط", + "total_time": "إجمالي الوقت" + }, + "history_date_item": { + "share": "مشاركة" + }, + + "comments": { + "save": "حفظ", + "cancel": "إلغاء", + "placeholder": "اكتب تعليقاً...", + "submit": "نشر", + "submitting": "جاري النشر...", + "title": "التعليقات", + "empty": "لا توجد تعليقات بعد. كن أول من يعلق!", + "load_more": "تحميل المزيد", + "show": "إظهار التعليقات", + "hide": "إخفاء التعليقات" + +}, +"reports": { + "title": "إحصائيات القناة", + "subtitle": "نظرة مفصلة على نشاط مساحة العمل الخاصة بك.", + "refresh": "تحديث", + "generate": "إنشاء التقرير", + "generating": "جاري الإنشاء...", + "from": "من", + "to": "إلى", + "empty_state": "لم يتم العثور على مدخلات", + "empty_hint": "جرّب اختيار نطاق زمني مختلف.", + "quick_stats": { + "title": "إحصائيات سريعة", + "total_entries": "إجمالي المدخلات", + "total_time": "إجمالي الوقت", + "daily_avg": "المعدل اليومي", + "labels_per_day": "تصنيفات يومياً" + }, + "charts": { + "time_distribution": "توزيع الوقت", + "most_used": "التصنيفات الأكثر استخداماً", + "avg_per_label": "متوسط الوقت لكل تصنيف" + } + } +} \ No newline at end of file diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json new file mode 100644 index 0000000..1411d86 --- /dev/null +++ b/frontend/src/locales/en.json @@ -0,0 +1,226 @@ +{ + "message": { + "hello": "hello" + }, + "login": { + "welcome_back": "Welcome Back", + "email": "Email", + "password": "Password", + "login": "Login", + "dont_have_account": "Don't have an account?", + "sign_up_here": "Sign Up Here" + }, + "register": { + "create_account": "Create Account", + "name": "Name", + "email": "Email", + "password": "Password", + "confirm_password": "Confirm Password", + "sign_up": "Sign Up", + "you_already_have_account": "You already have an account? ", + "login": "Login" + }, + "about": { + "title": "About", + "p1": "TimeTracker is a personal productivity tool that helps you log and understand how you spend your time every day. Simply add time entries with a label, start time, and end time — and the app takes care of the rest.", + "p2": "You can share your daily activity logs with channels — groups of people you collaborate with. Channel members can view your shared days, see your time entries, and leave comments. It's a simple way to stay transparent with your team without the overhead of complex project management tools.", + "p3": "The reports feature gives you a clear picture of where your time goes. Select any date range and instantly see breakdowns by label, your most used activity types, and daily averages — all visualized with charts to make patterns easy to spot." + }, + + "nav": { + "home": "Home", + "about": "About", + "add": "Add Entry", + "sign_out": "Sign Out", + "channels": "Channels", + "shared_days": "Shared Days", + "reports": "Reports", + "invitations": "Invitations" + }, + "addForm": { + "label": "Label", + "start_time": "Start Time", + "end_time": "End Time", + "add_entry": "Add Entry" + }, + "home": { + "history": "History", + "hide": "Hide", + "total_time": "Total Time", + "total_entries": "Entries", + "avg_per_entry": "Avg per Entry", + "load_more": "Load more", + "add_new_entry": "Add New Entry", + "today_entries": "Today's Entries", + "entries_of": "Time Entries of", + "error": "Oops! Error encountered", + "loading": "Loading entries...", + "error_loading_dates": "Error loading dates", + "no_dates": "No dates available", + "search_placeholder": "Search...", + "sort_default": "Default", + "sort_oldest": "Oldest", + "sort_latest": "Latest" + }, + "single_entry": { + "hour": "0 hours | {n} hour | {n} hours", + "minute": "0 minutes | {n} minute | {n} minutes", + "confirm_delete": "Are you sure?", + "yes": "Yes", + "no": "No" + }, + "update": { + "label": "Label", + "start_time": "Start Time", + "end_time": "End Time", + "loading": "Updating...", + "update": "Update", + "save_changes": "Save Changes", + "cancel": "Cancel" + }, + "channel_list": { + "title": "Channels", + "new_channel": "New Channel", + "my_channels": "My Channels", + "member_of": "Member Of", + "no_owned": "You have no channels yet.", + "no_joined": "You are not a member of any channel yet.", + "loading": "Loading...", + "error": "Something went wrong", + "delete_confirm": "Are you sure you want to delete this channel?", + "create_first": "Create your first channel" + }, + "create_channel": { + "title": "Create Channel", + "name": "Name", + "description": "Description", + "submit": "Create Channel", + "loading": "Loading..." + }, + "channel": { + "overview": "Overview", + "members": "Members", + "shared_days": "Shared Days", + "settings": "Settings", + "coming_soon": "Coming soon...", + "delete_channel": "Delete Channel", + "delete_warning": "This action is permanent and cannot be undone.", + "delete_confirm": "Are you sure you want to delete this channel?", + "yes": "Yes, delete", + "no": "Cancel", + "loading": "Loading...", + "error": "Something went wrong" + }, + "channel_members": { + "community": "Community", + "manage_members": "Manage your channel members and outgoing invites.", + "active_members": "Active Members", + "pending_invitations": "Pending Invitations", + "invite_member": "Invite Member", + "no_pending": "No pending invitations." + }, + "invite_modal": { + "title": "Invite to Channel", + "search_placeholder": "Search by name or email", + "cancel": "Cancel", + "send": "Send Invitation", + "sending": "Sending..." + }, + "channel_overview": { + "created": "Created", + "members": "Members", + "no_description": "No description provided for this channel." + }, + "channel_card": { + "no_description": "No description provided for this channel.", + "members": "members", + "enter": "Enter" + }, + "invitation_card": { + "cancel": "Cancel", + "accept": "Accept", + "decline": "Decline" + }, + "invitations_view": { + "title": "My Invitations", + "empty": "You haven't received any invitations yet.", + "loading": "Loading...", + "error": "Something went wrong" + }, + "share_day": { + "title": "Share Activity", + "entries_title": "Select Time Entries to Share", + "channels_title": "Share To Channels", + "loading_entries": "Loading entries...", + "loading_channels": "Loading channels...", + "error_entries": "Failed to load time entries. Please try again.", + "error_channels": "Failed to load channels. Please try again.", + "no_entries": "No time entries found for this day.", + "no_channels": "You don't own any channels yet.", + "members": "members", + "sharing": "Sharing...", + "submit": "Share Day", + "selected": "selected", + "ready": "Ready to share?", + "summary": "{entries} logs to {channels} channels" + }, + "shared_day_card": { + "day": "Day", + "entries": "entries" + }, + "my_shared_days": { + "title": "My Shared Days", + "shared_to": "Shared to {count} channel | Shared to {count} channels", + "empty_title": "No shared days yet", + "empty_message": "Go to your history and share a day to a channel." + }, + "channel_shared_days": { + "removed": "Day removed successfully!", + "empty_title": "No shared days yet", + "empty_message": "Shared logs will appear here once members post them." + }, + "shared_day_view": { + "summary": "Activity Log Summary", + "total_time": "Total Time" + }, + "history_date_item": { + "share": "Share" + }, + + "comments": { + "save": "Save", + "cancel": "Cancel", + "placeholder": "Write a comment...", + "submit": "Post", + "submitting": "Posting...", + "title": "Comments", + "empty": "No comments yet. Be the first to comment!", + "load_more": "Load more", + "show": "Show Comments", + "hide": "Hide Comments" + +}, +"reports": { + "title": "Channel Insights", + "subtitle": "Detailed overview of your workspace activity.", + "refresh": "Refresh", + "generate": "Generate Report", + "generating": "Generating...", + "from": "From", + "to": "To", + "empty_state": "No entries found", + "empty_hint": "Try selecting a different date range.", + "quick_stats": { + "title": "Quick Stats", + "total_entries": "Total Entries", + "total_time": "Total Time", + "daily_avg": "Daily Average", + "labels_per_day": "labels per day" + }, + "charts": { + "time_distribution": "Time Distribution", + "most_used": "Most Used Labels", + "avg_per_label": "Average Time per Label" + } + } +} \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js index 5262432..95e5473 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -6,129 +6,20 @@ import router from './router' import './assets/main.css' import Toast from 'vue-toastification' import 'vue-toastification/dist/index.css' +import en from './locales/en.json' +import ar from './locales/ar.json' + const app = createApp(App) +const i18n = createI18n({ + legacy: false, + locale: 'ar', + fallbackLocale: 'en', + messages: { en, ar } +}) + app.use(createPinia()) app.use(router) app.use(Toast) - -const i18n = createI18n({ -legacy: false, - locale: 'ar', - fallbackLocale: 'en', - messages: { - en: { - mesasge: { - hello: 'hello', - }, - login: { - welcome_back: 'Welcome Back', - email: 'Email', - password: 'Password', - login: 'Login', - dont_have_account: "Don't have an account?", - sign_up_here: "Sign Up Here" - }, - register:{ - create_account: "Create Account", - name: "Name", - email: "Email", - password: "Password", - confirm_password: "Confirm Password", - sign_up: "Sign Up", - you_already_have_account: "You already have an account? ", - login: "Login" - }, - nav: { - home: "Home", - about: "About", - add: "Add a new Time Entry", - sign_out: "Sign Out", - - }, - addForm: { - label:"Label", - start_time: "Start Time", - end_time: "End Time", - add_entry: "Add Entry" - }, - home: { - history: "History", - hide: "Hide", - add_new_entry: "Add New Entry", - today_entries: "Today's Entries", - entries_of: "Time Entries of", - error: "Oops! Error encountered", - loading: "Loading entries...", - error_loading_dates: "Error loading dates", - no_dates: "No dates available" - }, - single_entry: { - hour: '0 hours | {n} hour | {n} hours', - "confirm_delete": "Are you sure?", - "yes": "Yes", - "no": "No", - minute: '0 minutes | {n} minute | {n} minutes' - } - - }, - ar: { - message: { - hello: "مرحبا", - hide_history: 'اخفاء', - add_entry: 'أضف نشاط جديد' - }, - login: { - welcome_back: 'أهلا بعودتك', - email: 'الايميل', - password: 'كلمة المرور', - login:'تسجيل الدخول', - dont_have_account: "لا تملك حساب ؟", - sign_up_here: "أنشئ حساب هنا" - }, - register:{ - create_account: "أنشئ حساب", - name: "الاسم", - email: "الايميل", - password: "كلمة المرور", - confirm_password: "تأكيد كلمة المرور", - sign_up: "أنشئ حساب", - you_already_have_account: "لديك حساب؟", - login: "سجل الدخول" - }, - nav: { - home: "الصفحة الرئيسية", - about: "عن الموقع", - add: 'أضف نشاط جديد', - sign_out: "تسجيل الخروج", - - }, - addForm: { - label:"العنوان", - start_time: "وقت البدء", - end_time: "وقت الانتهاء", - add_entry: "أضف النشاط" - }, - home: { - history: "السجل", - hide: "إخفاء", - add_new_entry: "أضف نشاط جديد", - today_entries: "نشاطات اليوم", - entries_of: "نشاطات", - error: "عذراً! حدث خطأ", - loading: "جاري التحميل...", - error_loading_dates: "خطأ في تحميل التواريخ", - no_dates: "لا توجد تواريخ متاحة" - }, - single_entry: { - hour: 'صفر ساعات | {n} ساعة | {n} ساعات', - minute: 'صفر دقائق | {n} دقيقة | {n} دقائق', - "confirm_delete": "هل أنت متأكد؟", - "yes": "نعم", - "no": "لا" - } - }, - }, -}); app.use(i18n) -app.mount('#app') +app.mount('#app') \ No newline at end of file diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index ec7b1d9..a5d6e1d 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,10 +1,17 @@ import About from '@/views/About.vue' -import Home from '@/views/Home.vue' +import HomeView from '@/views/HomeView.vue' import { createRouter, createWebHistory } from 'vue-router' import Login from '@/views/Login.vue' import Register from '@/views/Register.vue' import AddForm from '@/views/AddForm.vue' import { authGaurd } from '@/middleware/auth' +import CreateChannelForm from '@/views/CreateChannelForm.vue' +import ChannelDashboard from '@/views/ChannelDashboard.vue' +import ChannelList from '@/views/ChannelList.vue' +import InvitationsView from '@/views/InvitationsView.vue' +import SharedDayDetails from '@/components/channel/shared-days/SharedDayDetails.vue' +import MySharedDays from '@/views/MySharedDays.vue' +import ReportsView from '@/views/ReportsView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -12,7 +19,7 @@ const router = createRouter({ { path: "/", name: "Home", - component: Home, + component: HomeView, meta: { requiresAuth: true } }, { @@ -22,11 +29,54 @@ const router = createRouter({ meta: { requiresAuth: true } }, { - path: "/add", - name: "Add", + path: "/add-entry", + name: "AddEntry", component: AddForm, meta: {requiresAuth: true} }, + { + path: "/channels", + name: "Channels", + component: ChannelList, + meta: {requiresAuth: true} + }, + { + path: "/add-channel", + name: "AddChannel", + component: CreateChannelForm, + meta: {requiresAuth: true} + }, + { + path: '/channels/:id', + name: 'Channel', + component: ChannelDashboard, + meta: {requiresAuth: true} + }, + { + path: '/my-shared-days', + name: 'MySharedDays', + component: MySharedDays, + meta: {requiresAuth: true} + + }, + { + path: '/channels/:channel_id/shared-days/:id', + name: 'SharedDay', + component: SharedDayDetails, + meta: {requiresAuth: true} + }, + { + path: '/my-invitations', + name: 'MyInvitations', + component: InvitationsView, + meta: {requiresAuth: true} + }, + { + path: '/reports', + name: 'Reports', + component: ReportsView, + meta: {requiresAuth: true} + }, { path: "/login", name: "Login", diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index a6a200c..24287a4 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -24,27 +24,32 @@ apiClient.interceptors.request.use( ); apiClient.interceptors.response.use( - (response) => response, - (error) => { - let message = ERROR_MESSAGES.GENERIC_ERROR; - if (error.response) { - const { status, data } = error.response; - - if (data && data.message) { - message = data.message; - } else { - message = ERROR_MESSAGES[status] || ERROR_MESSAGES.GENERIC_ERROR; - } - if (status === 401) { - } else { - message = ERROR_MESSAGES.NETWORK_ERROR; - } + (response) => response, + (error) => { + let message = ERROR_MESSAGES.GENERIC_ERROR; + + if (error.response) { + const { status, data } = error.response; - toast.error(message); - return Promise.reject(new Error(message)); - }} + if (data && data.message) { + message = data.message; + } else { + message = ERROR_MESSAGES[status] || ERROR_MESSAGES.GENERIC_ERROR; + } + + if (status === 401) { + // handle 401 here e.g. redirect to login + } + } else { + // only set NETWORK_ERROR if there's no response at all + message = ERROR_MESSAGES.NETWORK_ERROR; + } + + toast.error(message); + return Promise.reject(new Error(message)); + } ); -export default apiClient; \ No newline at end of file +export default apiClient; diff --git a/frontend/src/services/channelService.js b/frontend/src/services/channelService.js new file mode 100644 index 0000000..1607039 --- /dev/null +++ b/frontend/src/services/channelService.js @@ -0,0 +1,15 @@ +import apiClient from "./api"; + +const getChannels = (params) => apiClient.get('/api/channels', {params}) +const createChannel = (params) => apiClient.post('/api/channels', params) +const getChannel = (channelId) => apiClient.get(`/api/channels/${channelId}`) +const deleteChannel = (channelId) => apiClient.delete(`/api/channels/${channelId}`) + +export default { + + getChannels, + createChannel, + getChannel, + deleteChannel + +}; diff --git a/frontend/src/services/commentService.js b/frontend/src/services/commentService.js new file mode 100644 index 0000000..13ce4db --- /dev/null +++ b/frontend/src/services/commentService.js @@ -0,0 +1,16 @@ +// commentService.js +import apiClient from './api' + +const getComments = (channelId, sharedDayId, page = 1) => + apiClient.get(`/api/channels/${channelId}/shared-days/${sharedDayId}/comments?page=${page}`) + +const createComment = (channelId, sharedDayId, data) => + apiClient.post(`/api/channels/${channelId}/shared-days/${sharedDayId}/comments`, data) + +const updateComment = (channelId, sharedDayId, commentId, data) => + apiClient.put(`/api/channels/${channelId}/shared-days/${sharedDayId}/comments/${commentId}`, data) + +const deleteComment = (channelId, sharedDayId, commentId) => + apiClient.delete(`/api/channels/${channelId}/shared-days/${sharedDayId}/comments/${commentId}`) + +export default { getComments, createComment, updateComment, deleteComment } \ No newline at end of file diff --git a/frontend/src/services/invitationService.js b/frontend/src/services/invitationService.js new file mode 100644 index 0000000..26d4f82 --- /dev/null +++ b/frontend/src/services/invitationService.js @@ -0,0 +1,29 @@ +import apiClient from "./api"; +const createInvitation = (params, channel) => apiClient.post(`/api/channels/${channel}/invitations`, params) +const getUsers = (params) => apiClient.get('/api/users', {params}) +const getChanelInvitations = (channel) => apiClient.get(`/api/channels/${channel}/invitations`) +const getMyInvitations = ()=> apiClient.get('api/me/invitations'); +const cancelInvitation = (channel,invitation) =>apiClient.put(`/api/channels/${channel}/invitations/${invitation}`, + { + status : "cancelled" + } +) +const acceptInvitation = (channel,invitation) =>apiClient.put(`/api/channels/${channel}/invitations/${invitation}`, + { + status : "accepted" + } +) +const declineInvitation = (channel,invitation) =>apiClient.put(`/api/channels/${channel}/invitations/${invitation}`, + { + status : "declined" + } +) +export default{ + createInvitation, + getChanelInvitations, + getUsers, + getMyInvitations, + cancelInvitation, + acceptInvitation, + declineInvitation +} \ No newline at end of file diff --git a/frontend/src/services/reportService.js b/frontend/src/services/reportService.js new file mode 100644 index 0000000..676e6be --- /dev/null +++ b/frontend/src/services/reportService.js @@ -0,0 +1,3 @@ +import apiClient from "./api"; +const generateReport = (params) => apiClient.get('/api/reports', {params}) +export default { generateReport } \ No newline at end of file diff --git a/frontend/src/services/sharedDayService.js b/frontend/src/services/sharedDayService.js new file mode 100644 index 0000000..79f5786 --- /dev/null +++ b/frontend/src/services/sharedDayService.js @@ -0,0 +1,18 @@ +import apiClient from "./api"; + + +const createSharedDay = (params) => apiClient.post('/api/shared-days', params) +const getChannelSharedDays = (channel) => apiClient.get(`/api/channels/${channel}/shared-days`) +const getMySharedDays = ()=> apiClient.get('api/me/shared-days') +const getSharedDay = (channel, sharedDay) => apiClient.get(`/api/channels/${channel}/shared-days/${sharedDay}`) +const removeSharedDay = (channel, sharedDay) => apiClient.delete(`/api/channels/${channel}/shared-days/${sharedDay}`) + + +export default { + +createSharedDay, +getChannelSharedDays, +getMySharedDays, +getSharedDay, +removeSharedDay +}; diff --git a/frontend/src/services/timeEntryService.js b/frontend/src/services/timeEntryService.js index 874e93f..10af3c7 100644 --- a/frontend/src/services/timeEntryService.js +++ b/frontend/src/services/timeEntryService.js @@ -2,11 +2,13 @@ import apiClient from "./api"; const getTimeEntries = (params) => apiClient.get('/api/time-entries', {params}) const createTimeEntry = (params) => apiClient.post('/api/time-entries', params) +const updateTimeEntry = (params,id) => apiClient.put(`/api/time-entries/${id}`, params) const deleteTimeEntry = (id) => apiClient.delete(`/api/time-entries/${id}`) export default { getTimeEntries, createTimeEntry, + updateTimeEntry, deleteTimeEntry }; diff --git a/frontend/src/stores/commentStore.js b/frontend/src/stores/commentStore.js new file mode 100644 index 0000000..7178511 --- /dev/null +++ b/frontend/src/stores/commentStore.js @@ -0,0 +1,72 @@ +import { defineStore } from 'pinia' +import commentService from '@/services/commentService' + +export const useCommentStore = defineStore('commentStore', { + state: () => ({ + comments: [], + currentPage: 1, + lastPage: 1, + loading: false, + error: null, + }), + + getters: { + hasMore() { + return this.currentPage < this.lastPage + } + }, + + actions: { + async fetch(channelId, sharedDayId) { + this.loading = true + this.error = null + try { + const response = await commentService.getComments(channelId, sharedDayId, 1) + this.comments = response.data.data + this.lastPage = response.data.meta.last_page + this.currentPage = 1 + } catch (err) { + this.error = err.response?.data?.errors ?? err.message + } finally { + this.loading = false + } + }, + + async loadMore(channelId, sharedDayId) { + this.currentPage++ + this.loading = true + this.error = null + try { + const response = await commentService.getComments(channelId, sharedDayId, this.currentPage) + this.comments = [...this.comments, ...response.data.data] + this.lastPage = response.data.meta.last_page + } catch (err) { + this.currentPage-- // revert page increment if failed + this.error = err.response?.data?.errors ?? err.message + } finally { + this.loading = false + } + }, + + add(comment) { + this.comments.unshift(comment) + }, + + remove(id) { + this.comments = this.comments.filter(c => c.id !== id) + }, + + update(updated) { + const index = this.comments.findIndex(c => c.id === updated.id) + if (index !== -1) this.comments[index] = updated + }, + + reset() { + this.comments = [] + this.currentPage = 1 + this.lastPage = 1 + this.loading = false + this.error = null + } + } +}) \ No newline at end of file diff --git a/frontend/src/views/About.vue b/frontend/src/views/About.vue index ed7114c..8b286e1 100644 --- a/frontend/src/views/About.vue +++ b/frontend/src/views/About.vue @@ -1,3 +1,18 @@ + + \ No newline at end of file diff --git a/frontend/src/views/AddForm.vue b/frontend/src/views/AddForm.vue index f63ef8c..d808447 100644 --- a/frontend/src/views/AddForm.vue +++ b/frontend/src/views/AddForm.vue @@ -1,9 +1,11 @@