Implement ST-401 activity status flows
This commit is contained in:
parent
4966d493cc
commit
d0739736aa
7 changed files with 228 additions and 5 deletions
|
|
@ -5,9 +5,16 @@ import { buildPathWithQuery } from "@/lib/auth/navigation";
|
|||
import {
|
||||
ACTIVITY_IMPACT_LEVEL_VALUES,
|
||||
ACTIVITY_PRIORITY_LEVEL_VALUES,
|
||||
ACTIVITY_STATUS_VALUES,
|
||||
} from "@/lib/planning/options";
|
||||
import { createActivityForTodayForCurrentUser } from "@/lib/planning/service";
|
||||
import type { CreateActivitySubmission } from "@/lib/planning/types";
|
||||
import {
|
||||
createActivityForTodayForCurrentUser,
|
||||
updateActivityStatusForTodayForCurrentUser,
|
||||
} from "@/lib/planning/service";
|
||||
import type {
|
||||
CreateActivitySubmission,
|
||||
UpdateActivityStatusSubmission,
|
||||
} from "@/lib/planning/types";
|
||||
import {
|
||||
assertMaxLength,
|
||||
FormDataValidationError,
|
||||
|
|
@ -48,6 +55,20 @@ function buildCreateActivitySubmission(formData: FormData): CreateActivitySubmis
|
|||
};
|
||||
}
|
||||
|
||||
function buildUpdateActivityStatusSubmission(
|
||||
formData: FormData,
|
||||
): UpdateActivityStatusSubmission {
|
||||
return {
|
||||
activityId: getUuidValue(formData, "activityId", "invalid-activity-status"),
|
||||
status: getEnumValue(
|
||||
formData,
|
||||
"status",
|
||||
ACTIVITY_STATUS_VALUES,
|
||||
"invalid-activity-status",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createActivityAction(
|
||||
_previousState: null,
|
||||
formData: FormData,
|
||||
|
|
@ -69,3 +90,27 @@ export async function createActivityAction(
|
|||
redirect(buildPathWithQuery("/planning", { status: "activity-saved" }));
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function updateActivityStatusAction(
|
||||
_previousState: null,
|
||||
formData: FormData,
|
||||
): Promise<null> {
|
||||
try {
|
||||
await updateActivityStatusForTodayForCurrentUser(
|
||||
buildUpdateActivityStatusSubmission(formData),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof FormDataValidationError) {
|
||||
redirect(buildPathWithQuery("/planning", { error: error.code }));
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message === "Ongeldige of niet-beschikbare activiteit.") {
|
||||
redirect(buildPathWithQuery("/planning", { error: "invalid-activity-status" }));
|
||||
}
|
||||
|
||||
redirect(buildPathWithQuery("/planning", { error: "activity-status-failed" }));
|
||||
}
|
||||
|
||||
redirect(buildPathWithQuery("/planning", { status: "activity-status-saved" }));
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
68
components/planning/activity-status-actions.tsx
Normal file
68
components/planning/activity-status-actions.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { updateActivityStatusAction } from "@/app/planning/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ActivityStatus } from "@/lib/planning/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ActivityStatusActionsProps = {
|
||||
activityId: string;
|
||||
status: ActivityStatus;
|
||||
};
|
||||
|
||||
const statusOptions: Array<{
|
||||
value: ActivityStatus;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "planned", label: "Gepland" },
|
||||
{ value: "completed", label: "Uitgevoerd" },
|
||||
{ value: "skipped", label: "Geschipt" },
|
||||
{ value: "adjusted", label: "Aangepast" },
|
||||
];
|
||||
|
||||
export function ActivityStatusActions({
|
||||
activityId,
|
||||
status,
|
||||
}: ActivityStatusActionsProps) {
|
||||
const [, formAction, isPending] = useActionState(updateActivityStatusAction, null);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-3" aria-busy={isPending}>
|
||||
<input type="hidden" name="activityId" value={activityId} />
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label="Status van deze activiteit wijzigen"
|
||||
>
|
||||
{statusOptions.map((option) => {
|
||||
const isCurrent = option.value === status;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="submit"
|
||||
name="status"
|
||||
value={option.value}
|
||||
size="sm"
|
||||
variant={isCurrent ? "default" : "outline"}
|
||||
disabled={isPending}
|
||||
aria-pressed={isCurrent}
|
||||
className={cn(
|
||||
"rounded-full px-3",
|
||||
isPending && "pointer-events-none opacity-70",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs leading-6 text-muted-foreground" aria-live="polite">
|
||||
{isPending
|
||||
? "Status wordt opgeslagen..."
|
||||
: "Je kunt de status vandaag direct aanpassen zonder de activiteit te verwijderen."}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,8 +5,10 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ActivityStatusActions } from "@/components/planning/activity-status-actions";
|
||||
import { deriveActivityEnergyPoints } from "@/lib/planning/meter";
|
||||
import type { ActivityCategory, ActivityRecord } from "@/lib/planning/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TodayActivitiesListProps = {
|
||||
activities: ActivityRecord[];
|
||||
|
|
@ -41,6 +43,38 @@ function formatPriorityLabel(value: ActivityRecord["priorityLevel"]) {
|
|||
return "Normaal";
|
||||
}
|
||||
|
||||
function formatStatusLabel(value: ActivityRecord["status"]) {
|
||||
if (value === "completed") {
|
||||
return "Uitgevoerd";
|
||||
}
|
||||
|
||||
if (value === "skipped") {
|
||||
return "Overgeslagen";
|
||||
}
|
||||
|
||||
if (value === "adjusted") {
|
||||
return "Aangepast";
|
||||
}
|
||||
|
||||
return "Gepland";
|
||||
}
|
||||
|
||||
function getStatusBadgeClassName(value: ActivityRecord["status"]) {
|
||||
if (value === "completed") {
|
||||
return "bg-success text-primary-foreground";
|
||||
}
|
||||
|
||||
if (value === "skipped") {
|
||||
return "bg-warning text-foreground";
|
||||
}
|
||||
|
||||
if (value === "adjusted") {
|
||||
return "bg-secondary text-secondary-foreground";
|
||||
}
|
||||
|
||||
return "bg-secondary text-secondary-foreground";
|
||||
}
|
||||
|
||||
export function TodayActivitiesList({
|
||||
activities,
|
||||
categories,
|
||||
|
|
@ -75,8 +109,13 @@ export function TodayActivitiesList({
|
|||
{getCategoryLabel(categories, activity.categoryId)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-secondary px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-secondary-foreground">
|
||||
Gepland
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em]",
|
||||
getStatusBadgeClassName(activity.status),
|
||||
)}
|
||||
>
|
||||
{formatStatusLabel(activity.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -95,6 +134,13 @@ export function TodayActivitiesList({
|
|||
{deriveActivityEnergyPoints(activity)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 border-t border-border/60 pt-4">
|
||||
<ActivityStatusActions
|
||||
activityId={activity.id}
|
||||
status={activity.status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -92,9 +92,12 @@ Status: `ST-301`, `ST-302`, `ST-304` en `ST-305` zijn inmiddels gerealiseerd in
|
|||
|
||||
Doel: de kernloop afronden door geplande activiteiten te evalueren en terug te zien.
|
||||
|
||||
Status: `ST-401` is inmiddels gerealiseerd in de app. De volgende logische stap
|
||||
ligt nu in `ST-402` en `ST-403`.
|
||||
|
||||
| Story ID | Titel | Type | Definition of done |
|
||||
| --- | --- | --- | --- |
|
||||
| ST-401 | Statusflows voor uitgevoerd, geskipt en aangepast bouwen | Build | Alle drie de statussen worden correct opgeslagen |
|
||||
| ST-401 | Statusflows voor uitgevoerd, geskipt en aangepast bouwen | Build | Afgerond: activiteiten van vandaag kunnen direct tussen de vier statussen wisselen |
|
||||
| ST-402 | Evaluatievelden toevoegen | UI | Contextuele velden verschijnen passend per status |
|
||||
| ST-403 | Ongeplande activiteiten ondersteunen | Build | Ongeplande activiteit telt mee in werkelijke totalen |
|
||||
| ST-404 | Dagoverzicht bouwen | UI | Gepland versus uitgevoerd en statusverdeling zijn zichtbaar |
|
||||
|
|
|
|||
|
|
@ -69,6 +69,11 @@ const planningStatusToasts: Record<string, StatusToast> = {
|
|||
title: "Activiteit gepland",
|
||||
message: "Je activiteit staat nu in je dagplanning van vandaag.",
|
||||
},
|
||||
"activity-status-saved": {
|
||||
variant: "success",
|
||||
title: "Activiteit bijgewerkt",
|
||||
message: "De status van je activiteit is opgeslagen.",
|
||||
},
|
||||
};
|
||||
|
||||
const planningErrorToasts: Record<string, StatusToast> = {
|
||||
|
|
@ -78,6 +83,16 @@ const planningErrorToasts: Record<string, StatusToast> = {
|
|||
message:
|
||||
"Controleer naam, categorie, duur, impact en prioriteit en probeer het opnieuw.",
|
||||
},
|
||||
"invalid-activity-status": {
|
||||
variant: "error",
|
||||
title: "Status niet opgeslagen",
|
||||
message: "De gekozen activiteit of status is ongeldig voor vandaag.",
|
||||
},
|
||||
"activity-status-failed": {
|
||||
variant: "error",
|
||||
title: "Status niet opgeslagen",
|
||||
message: "De activiteitstatus kon niet worden bijgewerkt. Probeer het opnieuw.",
|
||||
},
|
||||
};
|
||||
|
||||
export function getDashboardStatusToast(status: string | null): StatusToast | null {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
ActivitiesForDateStatus,
|
||||
ActivityStatus,
|
||||
SkipReason,
|
||||
UpdateActivityStatusSubmission,
|
||||
} from "@/lib/planning/types";
|
||||
import { ensureProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
|
@ -303,3 +304,43 @@ export async function createActivityForTodayForCurrentUser(
|
|||
|
||||
return mapActivityRow(data);
|
||||
}
|
||||
|
||||
export async function updateActivityStatusForTodayForCurrentUser(
|
||||
submission: UpdateActivityStatusSubmission,
|
||||
): Promise<ActivityRecord> {
|
||||
const user = await getAuthenticatedUser();
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
|
||||
}
|
||||
|
||||
const profileBundle = await ensureProfileBundleForCurrentUser();
|
||||
|
||||
if (!profileBundle) {
|
||||
throw new Error("Profielbundle ontbreekt voor de huidige gebruiker.");
|
||||
}
|
||||
|
||||
const activityDate = getLocalDateForTimezone(profileBundle.profile.timezone);
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("activities")
|
||||
.update({
|
||||
status: submission.status,
|
||||
})
|
||||
.eq("id", submission.activityId)
|
||||
.eq("user_id", user.id)
|
||||
.eq("activity_date", activityDate)
|
||||
.select(ACTIVITY_COLUMNS)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Activiteitstatus kon niet worden opgeslagen: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("Ongeldige of niet-beschikbare activiteit.");
|
||||
}
|
||||
|
||||
return mapActivityRow(data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ export type CreateActivitySubmission = {
|
|||
priorityLevel: ActivityPriorityLevel;
|
||||
};
|
||||
|
||||
export type UpdateActivityStatusSubmission = {
|
||||
activityId: string;
|
||||
status: ActivityStatus;
|
||||
};
|
||||
|
||||
export type ActivitiesForDateStatus = {
|
||||
timezone: string;
|
||||
activityDate: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue