Skip to content

Commit 2d005df

Browse files
catomeanclaude
andcommitted
refactor(intake): split IntakeDetailView god-file into detail/* sub-components (628→178)
The 628-line intake detail view mixed ~14 JSX sections in one return. Decompose into a thin composition + 7 co-located sub-components under intake/detail/ — pure view split, useIntakeDetail hook / types / ChecklistGroup untouched: - IntakeHeader · IntakeDeviceSummary · IntakeQcStatus (QC-status cluster) · IntakeChecklistSection · IntakePublishSection · IntakeTierChangeDialog · IntakeTimeline Behavior identical: verbatim JSX, every status-conditional preserved, multi- section children returned as Fragments so DOM order + space-y-6 rhythm are unchanged; derived view values computed once in the composition and threaded as props. Verified: full tsc exit 0, eslint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6481ac1 commit 2d005df

8 files changed

Lines changed: 822 additions & 522 deletions

src/app/admin/intake/IntakeDetailView.tsx

Lines changed: 72 additions & 522 deletions
Large diffs are not rendered by default.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
'use client'
2+
3+
import { useTranslations } from 'next-intl'
4+
import { Button } from '@/components/ui/button'
5+
import { AlertCircle } from 'lucide-react'
6+
import type { ChecklistResult } from '@/config/intake-checklist'
7+
import { INTAKE_STATUS } from '@/config/intake-status'
8+
import Heading from '@/components/admin/AdminHeading'
9+
import { ChecklistGroup } from '../ChecklistGroup'
10+
import type { DetailData } from '../types'
11+
12+
interface IntakeChecklistSectionProps {
13+
detail: DetailData
14+
checklistPendingItems: ReadonlySet<string>
15+
onSetChecklistResult: (
16+
itemId: string,
17+
result: ChecklistResult | null,
18+
notes?: string,
19+
options?: { secondPersonOverride?: boolean },
20+
) => void
21+
qcGate: boolean
22+
publishPrice: number
23+
publishing: boolean
24+
onStartQc: () => void
25+
startingQc: boolean
26+
onPublish: (options?: { skipQc?: boolean }) => void
27+
}
28+
29+
export function IntakeChecklistSection({
30+
detail,
31+
checklistPendingItems,
32+
onSetChecklistResult,
33+
qcGate,
34+
publishPrice,
35+
publishing,
36+
onStartQc,
37+
startingQc,
38+
onPublish,
39+
}: IntakeChecklistSectionProps) {
40+
const t = useTranslations('admin.intake.detail')
41+
return (
42+
<>
43+
{/* Checklist Groups */}
44+
<div className="space-y-4">
45+
{detail.checklist_grouped.map((group) => (
46+
<ChecklistGroup
47+
key={`${group.category}-${detail.marketplace_status}`}
48+
group={group}
49+
readOnly={detail.marketplace_status === INTAKE_STATUS.PUBLISHED}
50+
pendingItems={checklistPendingItems}
51+
onSetResult={onSetChecklistResult}
52+
/>
53+
))}
54+
</div>
55+
56+
{/* QC gate — quick capture of a device category that requires the
57+
checklist: no publishing until the workflow is started */}
58+
{qcGate && detail.marketplace_status !== INTAKE_STATUS.PUBLISHED && (
59+
<div className="border-2 border-warning-300 bg-warning-50 dark:bg-warning-900/20 rounded-lg p-4 space-y-3">
60+
<Heading level={3} className="font-medium flex items-center gap-2 text-warning-800 dark:text-warning-200">
61+
<AlertCircle className="w-4 h-4" /> {t('qcGate.heading')}
62+
</Heading>
63+
<p className="text-sm text-warning-700 dark:text-warning-200">{t('qcGate.body')}</p>
64+
<div className="flex flex-wrap items-center gap-2">
65+
<Button
66+
type="button"
67+
onClick={onStartQc}
68+
disabled={startingQc}
69+
variant="primary"
70+
size="sm"
71+
>
72+
{startingQc ? t('qcGate.starting') : t('qcGate.start')}
73+
</Button>
74+
{/* Same escape hatch as the checklist path: one click, audited,
75+
listing without Prüfsiegel. */}
76+
<Button
77+
type="button"
78+
onClick={() => onPublish({ skipQc: true })}
79+
disabled={publishing || publishPrice <= 0}
80+
variant="outline"
81+
size="sm"
82+
title={t('publishUntestedTitle')}
83+
>
84+
{publishing ? t('publishing') : t('publishUntested')}
85+
</Button>
86+
</div>
87+
</div>
88+
)}
89+
</>
90+
)
91+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use client'
2+
3+
import { useTranslations } from 'next-intl'
4+
import { Image as ImageIcon } from 'lucide-react'
5+
import { KATEGORIEN, getConditionLabel } from '@/config/erfassung'
6+
import { formatDateShort } from '@/lib/date-formats'
7+
import type { DetailData } from '../types'
8+
9+
interface IntakeDeviceSummaryProps {
10+
detail: DetailData
11+
}
12+
13+
export function IntakeDeviceSummary({ detail }: IntakeDeviceSummaryProps) {
14+
const t = useTranslations('admin.intake.detail')
15+
return (
16+
<div className="bg-surface-base border rounded-lg p-4">
17+
<div className="flex gap-4">
18+
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-lg border border-subtle bg-surface-raised">
19+
{detail.image_url ? (
20+
21+
<img src={detail.image_url} alt={`${detail.brand} ${detail.product_name}`} className="h-full w-full object-cover" />
22+
) : (
23+
<div className="flex h-full w-full items-center justify-center">
24+
<ImageIcon className="h-8 w-8 text-text-muted" aria-hidden="true" />
25+
</div>
26+
)}
27+
</div>
28+
<dl className="grid flex-1 grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
29+
<div>
30+
<dt className="text-xs text-text-tertiary">{t('device.condition')}</dt>
31+
<dd className="font-medium text-text-primary">{getConditionLabel(detail.condition)}</dd>
32+
</div>
33+
<div>
34+
<dt className="text-xs text-text-tertiary">{t('device.category')}</dt>
35+
<dd className="font-medium text-text-primary">
36+
{KATEGORIEN.find(k => k.value === detail.category)?.label || '—'}
37+
</dd>
38+
</div>
39+
<div>
40+
<dt className="text-xs text-text-tertiary">{t('device.price')}</dt>
41+
<dd className="font-medium text-text-primary tabular-nums">
42+
{detail.selling_price_chf != null ? `CHF ${Number(detail.selling_price_chf).toFixed(2)}` : '—'}
43+
</dd>
44+
</div>
45+
<div className="col-span-2 sm:col-span-3">
46+
<dt className="text-xs text-text-tertiary">{t('device.captured')}</dt>
47+
<dd className="text-text-secondary">
48+
{formatDateShort(detail.created_at)}
49+
{detail.created_by_name ? ` · ${detail.created_by_name}` : ''}
50+
</dd>
51+
</div>
52+
</dl>
53+
</div>
54+
{detail.short_description && (
55+
<p className="mt-3 border-t border-subtle pt-3 text-sm text-text-secondary">
56+
{detail.short_description}
57+
</p>
58+
)}
59+
</div>
60+
)
61+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
'use client'
2+
3+
import Link from 'next/link'
4+
import { useTranslations } from 'next-intl'
5+
import { adminInteractive } from '@/lib/admin-ui'
6+
import { Button } from '@/components/ui/button'
7+
import { Check, RefreshCw, AlertCircle, ArrowDownUp, QrCode } from 'lucide-react'
8+
import { ROUTES } from '@/config/routes'
9+
import {
10+
INTAKE_TIERS,
11+
INTAKE_TIER_LABELS,
12+
INTAKE_TIER_ICONS,
13+
QUICK_CAPTURE_LABEL,
14+
QUICK_CAPTURE_ICON,
15+
} from '@/config/intake-checklist'
16+
import type { IntakeTier } from '@/config/intake-checklist'
17+
import { INTAKE_STATUS } from '@/config/intake-status'
18+
import { LISTING_STATUS } from '@/config/marketplace'
19+
import Heading from '@/components/admin/AdminHeading'
20+
import type { DetailData } from '../types'
21+
22+
interface IntakeHeaderProps {
23+
detail: DetailData
24+
onBack: () => void
25+
onRefresh: () => void
26+
setNewTier: (tier: IntakeTier) => void
27+
setShowTierChange: (show: boolean) => void
28+
}
29+
30+
export function IntakeHeader({ detail, onBack, onRefresh, setNewTier, setShowTierChange }: IntakeHeaderProps) {
31+
const t = useTranslations('admin.intake.detail')
32+
return (
33+
<div className="flex items-start justify-between">
34+
<div>
35+
<Button
36+
variant="ghost"
37+
size="sm"
38+
onClick={onBack}
39+
className="text-sm text-action hover:underline mb-2 flex items-center gap-1"
40+
>
41+
{t('backToPipeline')}
42+
</Button>
43+
<Heading level={2} className="text-lg font-semibold">{detail.brand} {detail.product_name}</Heading>
44+
<div className="flex items-center gap-3 text-sm text-text-tertiary mt-1">
45+
<span className="font-mono">{detail.item_uuid}</span>
46+
<span>
47+
{detail.intake_tier
48+
? <>{INTAKE_TIER_ICONS[detail.intake_tier]} {INTAKE_TIER_LABELS[detail.intake_tier]}</>
49+
: <>{QUICK_CAPTURE_ICON} {QUICK_CAPTURE_LABEL}</>}
50+
</span>
51+
{detail.source_donation_id && (
52+
<span className="text-action">{detail.donor_name ? t('donationWithName', { name: detail.donor_name }) : t('donation')}</span>
53+
)}
54+
</div>
55+
</div>
56+
57+
<div className="flex items-center gap-2 flex-wrap justify-end">
58+
<Link
59+
href={ROUTES.admin.intakeLabel(detail.id)}
60+
className={`flex items-center gap-1 px-2 py-1.5 text-xs border rounded-lg min-h-11 sm:min-h-0 ${adminInteractive.rowHover}`}
61+
title={t('printLabelTitle')}
62+
>
63+
<QrCode className="w-3.5 h-3.5" /> {t('printLabel')}
64+
</Link>
65+
{detail.marketplace_status === INTAKE_STATUS.PUBLISHED ? (
66+
detail.listing_id && detail.listing_status === LISTING_STATUS.ACTIVE ? (
67+
<Link
68+
href={ROUTES.public.marketplaceListing(detail.listing_id)}
69+
target="_blank"
70+
className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm bg-action-muted text-action hover:underline"
71+
>
72+
<Check className="w-4 h-4" /> {t('inShop')}
73+
</Link>
74+
) : detail.listing_status === LISTING_STATUS.SOLD ? (
75+
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm bg-surface-overlay text-text-secondary">
76+
<Check className="w-4 h-4" /> {t('listingSold')}
77+
</span>
78+
) : (
79+
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-200">
80+
<AlertCircle className="w-4 h-4" /> {t('listingInactive')}
81+
</span>
82+
)
83+
) : (
84+
<>
85+
{detail.intake_tier && (
86+
<Button
87+
type="button"
88+
variant="outline"
89+
size="sm"
90+
onClick={() => { setNewTier(detail.intake_tier === INTAKE_TIERS.REFURBISH ? INTAKE_TIERS.PARTS : INTAKE_TIERS.REFURBISH); setShowTierChange(true) }}
91+
className={`flex items-center gap-1 px-2 py-1.5 text-xs border rounded-lg ${adminInteractive.rowHover}`}
92+
title={t('changeTier')}
93+
>
94+
<ArrowDownUp className="w-3.5 h-3.5" /> {t('changeTier')}
95+
</Button>
96+
)}
97+
<Button onClick={onRefresh} variant="ghost" size="icon" title={t('refresh')}>
98+
<RefreshCw className="w-4 h-4" />
99+
</Button>
100+
</>
101+
)}
102+
</div>
103+
</div>
104+
)
105+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
'use client'
2+
3+
import Link from 'next/link'
4+
import { useTranslations } from 'next-intl'
5+
import { adminInteractive } from '@/lib/admin-ui'
6+
import { Button } from '@/components/ui/button'
7+
import { Input } from '@/components/ui/input'
8+
import { ExternalLink, AlertCircle, ClipboardList } from 'lucide-react'
9+
import { ROUTES } from '@/config/routes'
10+
import { INTAKE_TIERS } from '@/config/intake-checklist'
11+
import { INTAKE_STATUS } from '@/config/intake-status'
12+
import Heading from '@/components/admin/AdminHeading'
13+
import type { DetailData, ChecklistItemWithState } from '../types'
14+
15+
interface IntakePublishSectionProps {
16+
detail: DetailData
17+
qcGate: boolean
18+
openRequired: ChecklistItemWithState[]
19+
publishPrice: number
20+
setPublishPrice: (price: number) => void
21+
publishing: boolean
22+
onPublish: (options?: { skipQc?: boolean }) => void
23+
}
24+
25+
export function IntakePublishSection({
26+
detail,
27+
qcGate,
28+
openRequired,
29+
publishPrice,
30+
setPublishPrice,
31+
publishing,
32+
onPublish,
33+
}: IntakePublishSectionProps) {
34+
const t = useTranslations('admin.intake.detail')
35+
36+
// Publish Section — refurbish-tier items (checklist-gated) and quick
37+
// captures of accessory categories (no QC required)
38+
if (!(!detail.checklist_complete && (detail.intake_tier === INTAKE_TIERS.REFURBISH || (detail.intake_tier === null && !qcGate)) && detail.marketplace_status !== INTAKE_STATUS.PUBLISHED)) {
39+
return null
40+
}
41+
42+
return (
43+
<div className={`border-2 rounded-lg p-4 ${
44+
detail.checklist_complete
45+
? 'border-strong bg-action-muted'
46+
: 'border bg-surface-raised'
47+
}`}>
48+
<Heading level={3} className="font-medium mb-3 flex items-center gap-2">
49+
<ExternalLink className="w-4 h-4" />
50+
{t('publishHeading')}
51+
</Heading>
52+
53+
{!detail.checklist_complete && (
54+
<div className="flex items-start gap-2 mb-3 text-sm text-warning-700 dark:text-warning-200 bg-warning-50 dark:bg-warning-900/20 p-2 rounded-sm">
55+
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
56+
<span>
57+
{t('publishGate')}
58+
{openRequired.length > 0 && (
59+
<> {t('publishGateMissing', { items: openRequired.map(i => i.label).join(', ') })}</>
60+
)}
61+
</span>
62+
</div>
63+
)}
64+
65+
<div className="flex flex-wrap items-end gap-3">
66+
<div>
67+
<label className="block text-sm font-medium mb-1">{t('sellingPriceLabel')}</label>
68+
<Input
69+
type="number"
70+
value={publishPrice || ''}
71+
onChange={(e) => setPublishPrice(Number(e.target.value))}
72+
min={0}
73+
className="w-32"
74+
/>
75+
</div>
76+
<Button
77+
onClick={() => onPublish()}
78+
disabled={!detail.checklist_complete || publishing || publishPrice <= 0}
79+
variant="primary"
80+
size="sm"
81+
>
82+
{publishing ? t('publishing') : t('publishNow')}
83+
</Button>
84+
{/* Deliberate escape hatch: publish now, explicitly WITHOUT the
85+
Prüfsiegel (audited; blocked if a check actually failed). */}
86+
{!detail.checklist_failed && (
87+
<Button
88+
onClick={() => onPublish({ skipQc: true })}
89+
disabled={publishing || publishPrice <= 0}
90+
variant="outline"
91+
size="sm"
92+
title={t('publishUntestedTitle')}
93+
>
94+
{publishing ? t('publishing') : t('publishUntested')}
95+
</Button>
96+
)}
97+
{detail.checklist_complete && (
98+
<Link
99+
href={`${ROUTES.admin.intakeCapture}?edit=${detail.id}&returnTo=${encodeURIComponent(ROUTES.admin.intakeDetail(detail.id))}`}
100+
className={`inline-flex items-center gap-1.5 px-4 py-2 border border-default text-text-secondary rounded-lg ${adminInteractive.rowHover} text-sm font-medium`}
101+
title={t('openFullErfassungTitle')}
102+
>
103+
<ClipboardList className="w-4 h-4" />
104+
{t('openFullErfassung')}
105+
</Link>
106+
)}
107+
</div>
108+
</div>
109+
)
110+
}

0 commit comments

Comments
 (0)