76b1e712b9
Works through every numbered item in NextSteps260913.md section 3, plus the three known verification issues in section 4.1. Surface what the backend already computed: - 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies - 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible - 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt) Calendar cluster (one pass over the holiday data): - new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides, paginated four years at a time - 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal - 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact - 3.7 click month/year to advance - 3.8 per-year red-days list in Settings, statutory days plus overrides i18n: - 3.5 five backend-generated Swedish surfaces converted to message_key + data, following the tips_service pattern; old string fields replaced, not kept Flexible wishes (3.9): - PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function, deterministic tie-break, two-pass ordering, test matrix rows 15-24 - backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator - frontend: season-preset toggle in WishEditor, unplaced outcome rendering Fixes (4.1): - pin typecheck to the local tsc rather than npx resolving a newer TypeScript - ignore generated coverage/ and dist/ in the ESLint flat config - fix the flaky usePlanApply test properly, with waitFor rather than a retry Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines. Not yet verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
130 lines
4.2 KiB
TypeScript
130 lines
4.2 KiB
TypeScript
import { ReactNode, useState } from 'react'
|
|
import { useWorkspace } from '@/context'
|
|
import { useLeaveWishes, usePlanProposal, useWorkspaceMembers, usePlanApply } from '@/hooks'
|
|
import type { LeaveWishCreateRequest, LeaveWishUpdateRequest } from '@/types'
|
|
import { useTranslation } from '@/i18n'
|
|
import { Layout } from '@/components/layout/Layout'
|
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
|
import { WishEditor } from '@/components/planning/WishEditor'
|
|
import { PlanResults } from '@/components/planning/PlanResults'
|
|
import './plan.css'
|
|
|
|
export function Plan(): ReactNode {
|
|
const { t } = useTranslation()
|
|
const { workspace, isLoading: wsLoading } = useWorkspace()
|
|
const workspaceId = workspace?.id ?? null
|
|
const {
|
|
data: wishes,
|
|
isLoading: wishesLoading,
|
|
add: addWish,
|
|
update: updateWish,
|
|
remove: removeWish,
|
|
} = useLeaveWishes(workspaceId)
|
|
const { data: members } = useWorkspaceMembers(workspaceId)
|
|
const { data: proposal, isLoading: isProposing, error: proposalError, propose } =
|
|
usePlanProposal(workspaceId)
|
|
const { isLoading: isApplying, error: applyError, apply } = usePlanApply()
|
|
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [wishError, setWishError] = useState<string | null>(null)
|
|
|
|
const isLoading = wsLoading || wishesLoading
|
|
|
|
if (isLoading) {
|
|
return <LoadingSpinner message={t('plan.loading')} />
|
|
}
|
|
|
|
const handleAdd = (payload: LeaveWishCreateRequest) => {
|
|
setWishError(null)
|
|
setIsSaving(true)
|
|
addWish(payload)
|
|
.catch((err: unknown) => setWishError(err instanceof Error ? err.message : t('plan.addWishError')))
|
|
.finally(() => setIsSaving(false))
|
|
}
|
|
|
|
const handleUpdate = (id: string, patch: LeaveWishUpdateRequest) => {
|
|
setWishError(null)
|
|
setIsSaving(true)
|
|
updateWish(id, patch)
|
|
.catch((err: unknown) => setWishError(err instanceof Error ? err.message : t('plan.updateWishError')))
|
|
.finally(() => setIsSaving(false))
|
|
}
|
|
|
|
const handleRemove = (id: string) => {
|
|
setWishError(null)
|
|
setIsSaving(true)
|
|
removeWish(id)
|
|
.catch((err: unknown) => setWishError(err instanceof Error ? err.message : t('plan.removeWishError')))
|
|
.finally(() => setIsSaving(false))
|
|
}
|
|
|
|
const handleApplyPlan = async (days: import('@/types').ProposedDayApplyItem[], replace: boolean) => {
|
|
if (!workspaceId) throw new Error('Workspace not available')
|
|
return apply(workspaceId, days, replace)
|
|
}
|
|
|
|
return (
|
|
<Layout>
|
|
<div data-testid="plan-page" className="plan-page">
|
|
<h1>{t('plan.title')}</h1>
|
|
<p className="plan-page__intro">{t('plan.intro')}</p>
|
|
|
|
{wishError && (
|
|
<div className="plan-page__error" role="alert" data-testid="wish-error">
|
|
{wishError}
|
|
</div>
|
|
)}
|
|
|
|
<section className="plan-page__section">
|
|
<h2>{t('plan.wishesTitle')}</h2>
|
|
{workspaceId && (
|
|
<WishEditor
|
|
workspaceId={workspaceId}
|
|
wishes={wishes}
|
|
members={members}
|
|
isSaving={isSaving}
|
|
onAdd={handleAdd}
|
|
onUpdate={handleUpdate}
|
|
onRemove={handleRemove}
|
|
/>
|
|
)}
|
|
</section>
|
|
|
|
<section className="plan-page__section">
|
|
<button
|
|
type="button"
|
|
onClick={() => void propose()}
|
|
disabled={isProposing || wishes.length === 0}
|
|
data-testid="propose-plan-button"
|
|
>
|
|
{isProposing ? t('plan.calculating') : t('plan.proposeButton')}
|
|
</button>
|
|
{wishes.length === 0 && (
|
|
<p className="plan-page__help">{t('plan.addAtLeastOneWish')}</p>
|
|
)}
|
|
{proposalError && (
|
|
<div className="plan-page__error" role="alert" data-testid="proposal-error">
|
|
{proposalError}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{applyError && (
|
|
<div className="plan-page__error" role="alert" data-testid="apply-error">
|
|
{applyError}
|
|
</div>
|
|
)}
|
|
|
|
{proposal && (
|
|
<PlanResults
|
|
proposal={proposal}
|
|
members={members}
|
|
isApplying={isApplying}
|
|
onApply={handleApplyPlan}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Layout>
|
|
)
|
|
}
|