floor-planner/src/components/CutList.tsx
2026-09-11 22:14:24 +01:00

355 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { MapPin, X } from 'lucide-react';
import type { FlooringPlan, Project, SkirtingPlan } from '../lib/types';
import { cutMm, mm } from '../lib/format';
import {
flooringCuts,
groupFlooringCuts,
groupSkirtingCuts,
skirtingCuts,
skirtingEndLabel,
uncutFlooring,
uncutSkirting,
} from '../lib/cut-list';
import { boardSearchLabel, findBoardMatches } from '../lib/board-tracking';
import { PERSISTENCE_LIMITS } from '../lib/limits';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
function BoardHeader({
label,
size,
done,
toggle,
locate,
}: {
label: string;
size: string;
done: boolean;
toggle: () => void;
locate: () => void;
}) {
return (
<header className="board-group-header">
<h4>
{label.charAt(0).toUpperCase() + label.slice(1)}
<span>{size}</span>
</h4>
<div className="board-group-actions">
<label className="board-done-control">
<input
type="checkbox"
aria-label={`Mark ${label} done`}
checked={done}
onChange={toggle}
/>
<span>{done ? 'Done' : 'Mark done'}</span>
</label>
<button
className="button board-locate"
aria-label={`Show ${label} on plan`}
onClick={locate}
>
<MapPin size={15} aria-hidden="true" />
Show on plan
</button>
</div>
</header>
);
}
interface Props {
project: Project;
flooring: FlooringPlan;
skirting: SkirtingPlan;
select: (id: string | null, roomId?: string) => void;
completedBoards: string[];
toggleBoard: (key: string) => void;
search: string;
setSearch: (query: string) => void;
locateBoard: (key: string) => void;
locatePiece: (id: string, roomId: string) => void;
}
export function CutList({
project,
flooring,
skirting,
select,
completedBoards,
toggleBoard,
search,
setSearch,
locateBoard,
locatePiece,
}: Props) {
const floorCuts = flooringCuts(flooring),
skirtCuts = skirtingCuts(skirting);
const wholeFloor = uncutFlooring(flooring, project.flooring),
wholeSkirt = uncutSkirting(skirting);
const floorGroups = new Map(
groupFlooringCuts(flooring).map((group) => [group[0].stockId, group]),
);
const skirtGroups = new Map(
groupSkirtingCuts(skirting).map((group) => [group[0].stockId, group]),
);
const searching = search.trim().length > 0;
const matches = findBoardMatches(project, flooring, skirting, search);
const matchedBoards = new Set(matches.boardKeys),
matchedPieces = new Set(matches.pieceIds);
const completed = new Set(completedBoards);
// Keep every cut from a matching source together, even when only one piece or room matched.
const floorStocks = flooring.stocks.filter((stock) =>
searching ? matchedBoards.has(`floor:${stock.id}`) : floorGroups.has(stock.id),
);
const skirtStocks = skirting.stocks.filter((stock) =>
searching ? matchedBoards.has(`skirting:${stock.id}`) : skirtGroups.has(stock.id),
);
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
return (
<section className="cut-list-card" aria-labelledby="cut-plan-title">
<div className="cut-list-header">
<div>
<h2 id="cut-plan-title">Cut plan</h2>
<p className="helper">
Each group comes from one board. Select a piece for its cutting diagram.
</p>
</div>
</div>
<div className="cut-search">
<label htmlFor="board-search">Find a cut or room</label>
<div className="cut-search-control">
<input
id="board-search"
type="search"
value={boardSearchLabel(project, flooring, skirting, search)}
maxLength={PERSISTENCE_LIMITS.searchLength}
onChange={(event) => setSearch(event.target.value)}
placeholder="F12, A2, or a room name"
autoComplete="off"
/>
{search.length > 0 && (
<button
className="button clear-search"
aria-label="Clear search"
onClick={() => setSearch('')}
>
<X size={16} aria-hidden="true" />
Clear
</button>
)}
</div>
{searching && (
<p className="search-results" role="status">
{matches.boardKeys.length
? `${matches.boardKeys.length} matching board${matches.boardKeys.length === 1 ? '' : 's'}`
: 'No matching boards or cuts.'}
</p>
)}
</div>
<section className="material-cuts flooring-cuts" aria-labelledby="flooring-cuts-title">
<h3 id="flooring-cuts-title">
Flooring cuts <span className="count-badge">{floorCuts.length}</span>
</h3>
<p className="cut-summary">
Buy {flooring.boardCount} boards · {wholeFloor.wholeBoards} laid uncut
{wholeFloor.readyOffcuts > 0 &&
` · ${wholeFloor.readyOffcuts} offcuts need no further cuts`}
</p>
{floorStocks.map((stock) => {
const key = `floor:${stock.id}`,
done = completed.has(key),
group = floorGroups.get(stock.id) ?? [],
label = group.length
? `cuts ${group.map((piece) => piece.id).join(' + ')}`
: 'uncut floorboard';
return (
<section
className={`stock-cut-group ${done ? 'is-done' : ''}`}
data-stock-id={stock.id}
data-board-key={key}
key={key}
aria-label={group.length ? `Flooring ${label}` : 'Uncut floorboard'}
>
<BoardHeader
label={label}
size={`${mm(project.flooring.length)} × ${mm(project.flooring.width)} mm`}
done={done}
toggle={() => toggleBoard(key)}
locate={() => locateBoard(key)}
/>
{group.length ? (
<ul className="board-cuts">
{group.map((piece) => (
<li
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(piece.id) ? 'search-match' : ''}`}
key={piece.id}
data-cut-id={piece.id}
>
<button
className="cut-preview-button flooring-preview-button"
aria-label={`Preview flooring cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<FlooringPreview piece={piece} compact />
</span>
<span className="cut-preview-code">
{piece.id} <span aria-hidden="true">↗</span>
</span>
</button>
<div className="board-cut-info">
<p className="cut-size">
{cutMm(piece.length, piece.blankLength)} ×{' '}
{cutMm(piece.width, piece.blankWidth)} mm
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Row {piece.row}
</p>
{piece.reused && (
<p className="cut-note">
{piece.cut
? 'Cut from the remaining offcut'
: 'Use the offcut as it is'}
</p>
)}
<button
className="button cut-locate"
aria-label={`Show cut ${piece.id} on plan`}
onClick={() => locatePiece(piece.id, piece.roomId)}
>
<MapPin size={14} aria-hidden="true" />
Show on plan
</button>
</div>
</li>
))}
</ul>
) : (
<p className="uncut-board-note">
Installed uncut ·{' '}
{flooring.pieces
.filter((piece) => piece.stockId === stock.id)
.map((piece) => roomName(piece.roomId))
.join(', ')}
</p>
)}
</section>
);
})}
{!floorStocks.length && (
<p className="empty-state">
{searching
? 'No matching floorboards.'
: flooring.pieces.length
? 'No flooring cuts needed.'
: 'Complete the outline and flooring measurements to calculate cuts.'}
</p>
)}
</section>
<section className="material-cuts skirting-cuts" aria-labelledby="skirting-cuts-title">
<h3 id="skirting-cuts-title">
Skirting cuts <span className="count-badge">{skirtCuts.length}</span>
</h3>
<p className="cut-summary">
Buy {skirting.stockCount} lengths ·{' '}
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} installed uncut
</p>
{skirtStocks.map((stock) => {
const key = `skirting:${stock.id}`,
done = completed.has(key),
group = skirtGroups.get(stock.id) ?? [],
label = group.length
? `cuts ${group.map((piece) => piece.id).join(' + ')}`
: 'uncut skirting board';
return (
<section
className={`stock-cut-group ${done ? 'is-done' : ''}`}
data-stock-id={stock.id}
data-board-key={key}
key={key}
aria-label={group.length ? `Skirting ${label}` : 'Uncut skirting board'}
>
<BoardHeader
label={label}
size={`${mm(stock.length)} mm`}
done={done}
toggle={() => toggleBoard(key)}
locate={() => locateBoard(key)}
/>
{group.length ? (
<ul className="board-cuts">
{group.map((piece) => (
<li
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(piece.id) ? 'search-match' : ''}`}
key={piece.id}
data-cut-id={piece.id}
>
<button
className="cut-preview-button skirting-preview-button"
aria-label={`Preview skirting cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<SkirtingPreview
piece={piece}
thickness={project.skirting.thickness}
compact
/>
</span>
<span className="cut-preview-code">
{piece.id} <span aria-hidden="true">↗</span>
</span>
</button>
<div className="board-cut-info">
<p className="cut-size">
{cutMm(piece.length, piece.blankLength)} mm{' '}
<span className="cut-note">long point</span>
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
</p>
<p className="cut-note">
Left: {skirtingEndLabel(piece, 'left')}
<br />
Right: {skirtingEndLabel(piece, 'right')}
</p>
<button
className="button cut-locate"
aria-label={`Show cut ${piece.id} on plan`}
onClick={() => locatePiece(piece.id, piece.roomId)}
>
<MapPin size={14} aria-hidden="true" />
Show on plan
</button>
</div>
</li>
))}
</ul>
) : (
<p className="uncut-board-note">
Installed uncut ·{' '}
{skirting.pieces
.filter((piece) => piece.stockId === stock.id)
.map((piece) => roomName(piece.roomId))
.join(', ')}
</p>
)}
</section>
);
})}
{!skirtStocks.length && (
<p className="empty-state">
{searching
? 'No matching skirting boards.'
: skirting.pieces.length
? 'No skirting cuts needed.'
: 'Complete the outline and skirting measurements to calculate cuts.'}
</p>
)}
</section>
</section>
);
}