Implement ST-402 activity evaluation fields
This commit is contained in:
parent
c3cd1de647
commit
57ade6a772
10 changed files with 344 additions and 6 deletions
|
|
@ -9,10 +9,12 @@ import {
|
|||
} from "@/lib/planning/options";
|
||||
import {
|
||||
createActivityForTodayForCurrentUser,
|
||||
updateActivityEvaluationForTodayForCurrentUser,
|
||||
updateActivityStatusForTodayForCurrentUser,
|
||||
} from "@/lib/planning/service";
|
||||
import type {
|
||||
CreateActivitySubmission,
|
||||
UpdateActivityEvaluationSubmission,
|
||||
UpdateActivityStatusSubmission,
|
||||
} from "@/lib/planning/types";
|
||||
import {
|
||||
|
|
@ -20,6 +22,8 @@ import {
|
|||
FormDataValidationError,
|
||||
getEnumValue,
|
||||
getIntegerValue,
|
||||
getOptionalString,
|
||||
getOptionalUuidValue,
|
||||
getRequiredString,
|
||||
getUuidValue,
|
||||
} from "@/lib/forms/parse";
|
||||
|
|
@ -69,6 +73,24 @@ function buildUpdateActivityStatusSubmission(
|
|||
};
|
||||
}
|
||||
|
||||
function buildUpdateActivityEvaluationSubmission(
|
||||
formData: FormData,
|
||||
): UpdateActivityEvaluationSubmission {
|
||||
const notes = getOptionalString(formData, "notes");
|
||||
|
||||
return {
|
||||
activityId: getUuidValue(formData, "activityId", "invalid-activity-evaluation"),
|
||||
skipReasonId: getOptionalUuidValue(
|
||||
formData,
|
||||
"skipReasonId",
|
||||
"invalid-activity-evaluation",
|
||||
),
|
||||
notes: notes
|
||||
? assertMaxLength(notes, 500, "invalid-activity-evaluation")
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createActivityAction(
|
||||
_previousState: null,
|
||||
formData: FormData,
|
||||
|
|
@ -114,3 +136,33 @@ export async function updateActivityStatusAction(
|
|||
redirect(buildPathWithQuery("/planning", { status: "activity-status-saved" }));
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function saveActivityEvaluationAction(
|
||||
_previousState: null,
|
||||
formData: FormData,
|
||||
): Promise<null> {
|
||||
try {
|
||||
await updateActivityEvaluationForTodayForCurrentUser(
|
||||
buildUpdateActivityEvaluationSubmission(formData),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof FormDataValidationError) {
|
||||
redirect(buildPathWithQuery("/planning", { error: error.code }));
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message === "Ongeldige of niet-beschikbare activiteit." ||
|
||||
error.message === "Skip-reden is verplicht voor een overgeslagen activiteit." ||
|
||||
error.message === "Toelichting is verplicht voor een aangepaste activiteit." ||
|
||||
error.message === "Ongeldige skip-reden.")
|
||||
) {
|
||||
redirect(buildPathWithQuery("/planning", { error: "invalid-activity-evaluation" }));
|
||||
}
|
||||
|
||||
redirect(buildPathWithQuery("/planning", { error: "activity-evaluation-failed" }));
|
||||
}
|
||||
|
||||
redirect(buildPathWithQuery("/planning", { status: "activity-evaluation-saved" }));
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps)
|
|||
<TodayActivitiesList
|
||||
activities={planningPageData.activities}
|
||||
categories={planningPageData.categories}
|
||||
skipReasons={planningPageData.skipReasons}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
|
|
|||
127
components/planning/activity-evaluation-fields.tsx
Normal file
127
components/planning/activity-evaluation-fields.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { saveActivityEvaluationAction } from "@/app/planning/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ActivityStatus, SkipReason } from "@/lib/planning/types";
|
||||
|
||||
type ActivityEvaluationFieldsProps = {
|
||||
activityId: string;
|
||||
status: ActivityStatus;
|
||||
skipReasons: SkipReason[];
|
||||
initialSkipReasonId: string | null;
|
||||
initialNotes: string | null;
|
||||
};
|
||||
|
||||
export function ActivityEvaluationFields({
|
||||
activityId,
|
||||
status,
|
||||
skipReasons,
|
||||
initialSkipReasonId,
|
||||
initialNotes,
|
||||
}: ActivityEvaluationFieldsProps) {
|
||||
const [, formAction, isPending] = useActionState(saveActivityEvaluationAction, null);
|
||||
const [skipReasonId, setSkipReasonId] = useState(
|
||||
initialSkipReasonId ?? skipReasons[0]?.id ?? "",
|
||||
);
|
||||
const [notes, setNotes] = useState(initialNotes ?? "");
|
||||
|
||||
if (status !== "skipped" && status !== "adjusted") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-4" aria-busy={isPending}>
|
||||
<input type="hidden" name="activityId" value={activityId} />
|
||||
{status === "skipped" ? (
|
||||
<>
|
||||
<input type="hidden" name="skipReasonId" value={skipReasonId} />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold text-foreground">
|
||||
Waarom is deze activiteit overgeslagen?
|
||||
</Label>
|
||||
<Select
|
||||
disabled={isPending}
|
||||
value={skipReasonId}
|
||||
onValueChange={(value) => setSkipReasonId(value ?? skipReasons[0]?.id ?? "")}
|
||||
>
|
||||
<SelectTrigger className="h-11 w-full rounded-[1.15rem] bg-background/80 px-4 text-sm">
|
||||
<SelectValue placeholder="Kies een skip-reden" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{skipReasons.map((skipReason) => (
|
||||
<SelectItem key={skipReason.id} value={skipReason.id}>
|
||||
{skipReason.labelNl}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`activity-notes-${activityId}`} className="text-sm font-semibold text-foreground">
|
||||
Extra toelichting
|
||||
</Label>
|
||||
<textarea
|
||||
id={`activity-notes-${activityId}`}
|
||||
name="notes"
|
||||
className="min-h-24 w-full rounded-[1.15rem] border border-input bg-background/80 px-4 py-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
disabled={isPending}
|
||||
maxLength={500}
|
||||
placeholder="Optioneel: wat speelde mee?"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`activity-notes-${activityId}`} className="text-sm font-semibold text-foreground">
|
||||
Wat heb je aangepast?
|
||||
</Label>
|
||||
<textarea
|
||||
id={`activity-notes-${activityId}`}
|
||||
name="notes"
|
||||
className="min-h-28 w-full rounded-[1.15rem] border border-input bg-background/80 px-4 py-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
disabled={isPending}
|
||||
maxLength={500}
|
||||
placeholder="Beschrijf kort wat je hebt aangepast aan duur, vorm of intensiteit."
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs leading-6 text-muted-foreground" aria-live="polite">
|
||||
{isPending
|
||||
? "Evaluatie wordt opgeslagen..."
|
||||
: status === "skipped"
|
||||
? "Voeg optioneel context toe, zodat later duidelijker is waarom deze activiteit niet doorging."
|
||||
: "Beschrijf kort wat je hebt aangepast, zodat dagreflectie later betekenisvoller wordt."}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={
|
||||
isPending ||
|
||||
(status === "skipped" && !skipReasonId) ||
|
||||
(status === "adjusted" && !notes.trim())
|
||||
}
|
||||
className="rounded-full px-4"
|
||||
>
|
||||
{isPending ? "Evaluatie opslaan..." : "Evaluatie opslaan"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ const statusOptions: Array<{
|
|||
}> = [
|
||||
{ value: "planned", label: "Gepland" },
|
||||
{ value: "completed", label: "Uitgevoerd" },
|
||||
{ value: "skipped", label: "Geschipt" },
|
||||
{ value: "skipped", label: "Overgeslagen" },
|
||||
{ value: "adjusted", label: "Aangepast" },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -5,14 +5,20 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ActivityEvaluationFields } from "@/components/planning/activity-evaluation-fields";
|
||||
import { ActivityStatusActions } from "@/components/planning/activity-status-actions";
|
||||
import { deriveActivityEnergyPoints } from "@/lib/planning/meter";
|
||||
import type { ActivityCategory, ActivityRecord } from "@/lib/planning/types";
|
||||
import type {
|
||||
ActivityCategory,
|
||||
ActivityRecord,
|
||||
SkipReason,
|
||||
} from "@/lib/planning/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TodayActivitiesListProps = {
|
||||
activities: ActivityRecord[];
|
||||
categories: ActivityCategory[];
|
||||
skipReasons: SkipReason[];
|
||||
};
|
||||
|
||||
function getCategoryLabel(categories: ActivityCategory[], categoryId: string) {
|
||||
|
|
@ -78,6 +84,7 @@ function getStatusBadgeClassName(value: ActivityRecord["status"]) {
|
|||
export function TodayActivitiesList({
|
||||
activities,
|
||||
categories,
|
||||
skipReasons,
|
||||
}: TodayActivitiesListProps) {
|
||||
return (
|
||||
<Card className="py-0">
|
||||
|
|
@ -141,6 +148,19 @@ export function TodayActivitiesList({
|
|||
status={activity.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(activity.status === "skipped" || activity.status === "adjusted") ? (
|
||||
<div className="mt-4 rounded-[var(--radius-lg)] border border-border/60 bg-card/60 p-4">
|
||||
<ActivityEvaluationFields
|
||||
key={`${activity.id}-${activity.status}`}
|
||||
activityId={activity.id}
|
||||
status={activity.status}
|
||||
skipReasons={skipReasons}
|
||||
initialSkipReasonId={activity.skipReasonId}
|
||||
initialNotes={activity.notes}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -92,13 +92,13 @@ 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`.
|
||||
Status: `ST-401` en `ST-402` zijn inmiddels gerealiseerd in de app. De volgende
|
||||
logische stap ligt nu in `ST-403`.
|
||||
|
||||
| Story ID | Titel | Type | Definition of done |
|
||||
| --- | --- | --- | --- |
|
||||
| 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-402 | Evaluatievelden toevoegen | UI | Afgerond: skip-reden en toelichting verschijnen passend per status en worden opgeslagen |
|
||||
| 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 |
|
||||
| ST-405 | Dagaggregaties server-side implementeren | Logic | Dagtotalen blijven consistent met individuele records |
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ const planningStatusToasts: Record<string, StatusToast> = {
|
|||
title: "Activiteit bijgewerkt",
|
||||
message: "De status van je activiteit is opgeslagen.",
|
||||
},
|
||||
"activity-evaluation-saved": {
|
||||
variant: "success",
|
||||
title: "Evaluatie opgeslagen",
|
||||
message: "De extra context bij deze activiteit is bijgewerkt.",
|
||||
},
|
||||
};
|
||||
|
||||
const planningErrorToasts: Record<string, StatusToast> = {
|
||||
|
|
@ -93,6 +98,17 @@ const planningErrorToasts: Record<string, StatusToast> = {
|
|||
title: "Status niet opgeslagen",
|
||||
message: "De activiteitstatus kon niet worden bijgewerkt. Probeer het opnieuw.",
|
||||
},
|
||||
"invalid-activity-evaluation": {
|
||||
variant: "error",
|
||||
title: "Evaluatie niet opgeslagen",
|
||||
message:
|
||||
"Controleer de skip-reden of toelichting en probeer het opnieuw.",
|
||||
},
|
||||
"activity-evaluation-failed": {
|
||||
variant: "error",
|
||||
title: "Evaluatie niet opgeslagen",
|
||||
message: "De extra context bij deze activiteit kon niet worden opgeslagen.",
|
||||
},
|
||||
};
|
||||
|
||||
export function getDashboardStatusToast(status: string | null): StatusToast | null {
|
||||
|
|
|
|||
|
|
@ -169,3 +169,21 @@ export function getUuidValue(
|
|||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getOptionalUuidValue(
|
||||
formData: FormData,
|
||||
key: string,
|
||||
errorCode: string,
|
||||
): string | null {
|
||||
const value = getOptionalString(formData, key);
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!UUID_VALUE_PATTERN.test(value)) {
|
||||
fail(errorCode);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
ActivitiesForDateStatus,
|
||||
ActivityStatus,
|
||||
SkipReason,
|
||||
UpdateActivityEvaluationSubmission,
|
||||
UpdateActivityStatusSubmission,
|
||||
} from "@/lib/planning/types";
|
||||
import { ensureProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||
|
|
@ -166,6 +167,26 @@ async function assertCategoryExists(
|
|||
}
|
||||
}
|
||||
|
||||
async function assertSkipReasonExists(
|
||||
supabase: SupabaseServerClient,
|
||||
skipReasonId: string,
|
||||
): Promise<void> {
|
||||
const { data, error } = await supabase
|
||||
.from("skip_reasons")
|
||||
.select("id")
|
||||
.eq("id", skipReasonId)
|
||||
.eq("is_active", true)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Skip-reden kon niet worden gevalideerd: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("Ongeldige skip-reden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listActivityCategories(): Promise<ActivityCategory[]> {
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase
|
||||
|
|
@ -246,8 +267,9 @@ export async function getPlanningPageDataForCurrentUser(): Promise<PlanningPageD
|
|||
return null;
|
||||
}
|
||||
|
||||
const [categories, activitiesStatus] = await Promise.all([
|
||||
const [categories, skipReasons, activitiesStatus] = await Promise.all([
|
||||
listActivityCategories(),
|
||||
listSkipReasons(),
|
||||
getTodayActivitiesForCurrentUser(),
|
||||
]);
|
||||
|
||||
|
|
@ -256,6 +278,7 @@ export async function getPlanningPageDataForCurrentUser(): Promise<PlanningPageD
|
|||
activityDate:
|
||||
activitiesStatus?.activityDate ?? getLocalDateForTimezone(profileBundle.profile.timezone),
|
||||
categories,
|
||||
skipReasons,
|
||||
activities: activitiesStatus?.activities ?? [],
|
||||
};
|
||||
}
|
||||
|
|
@ -344,3 +367,77 @@ export async function updateActivityStatusForTodayForCurrentUser(
|
|||
|
||||
return mapActivityRow(data);
|
||||
}
|
||||
|
||||
export async function updateActivityEvaluationForTodayForCurrentUser(
|
||||
submission: UpdateActivityEvaluationSubmission,
|
||||
): 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: existingActivity, error: existingActivityError } = await supabase
|
||||
.from("activities")
|
||||
.select(ACTIVITY_COLUMNS)
|
||||
.eq("id", submission.activityId)
|
||||
.eq("user_id", user.id)
|
||||
.eq("activity_date", activityDate)
|
||||
.maybeSingle();
|
||||
|
||||
if (existingActivityError) {
|
||||
throw new Error(
|
||||
`Activiteit voor evaluatie kon niet worden geladen: ${existingActivityError.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!existingActivity) {
|
||||
throw new Error("Ongeldige of niet-beschikbare activiteit.");
|
||||
}
|
||||
|
||||
let nextSkipReasonId: string | null = null;
|
||||
let nextNotes: string | null = null;
|
||||
|
||||
if (existingActivity.status === "skipped") {
|
||||
if (!submission.skipReasonId) {
|
||||
throw new Error("Skip-reden is verplicht voor een overgeslagen activiteit.");
|
||||
}
|
||||
|
||||
await assertSkipReasonExists(supabase, submission.skipReasonId);
|
||||
nextSkipReasonId = submission.skipReasonId;
|
||||
nextNotes = submission.notes;
|
||||
} else if (existingActivity.status === "adjusted") {
|
||||
if (!submission.notes) {
|
||||
throw new Error("Toelichting is verplicht voor een aangepaste activiteit.");
|
||||
}
|
||||
|
||||
nextNotes = submission.notes;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("activities")
|
||||
.update({
|
||||
skip_reason_id: nextSkipReasonId,
|
||||
notes: nextNotes,
|
||||
})
|
||||
.eq("id", existingActivity.id)
|
||||
.eq("user_id", user.id)
|
||||
.eq("activity_date", activityDate)
|
||||
.select(ACTIVITY_COLUMNS)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Evaluatiecontext kon niet worden opgeslagen: ${error.message}`);
|
||||
}
|
||||
|
||||
return mapActivityRow(data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ export type UpdateActivityStatusSubmission = {
|
|||
status: ActivityStatus;
|
||||
};
|
||||
|
||||
export type UpdateActivityEvaluationSubmission = {
|
||||
activityId: string;
|
||||
skipReasonId: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type ActivitiesForDateStatus = {
|
||||
timezone: string;
|
||||
activityDate: string;
|
||||
|
|
@ -68,5 +74,6 @@ export type PlanningPageData = {
|
|||
timezone: string;
|
||||
activityDate: string;
|
||||
categories: ActivityCategory[];
|
||||
skipReasons: SkipReason[];
|
||||
activities: ActivityRecord[];
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue