This commit is contained in:
Andras Schmelczer 2026-09-11 16:58:58 +01:00
commit 62a385e4c9

View file

@ -1,7 +1,6 @@
import { Download } from 'lucide-react';
import { MapPin, X } from 'lucide-react';
import type { FlooringPlan, Project, SkirtingPlan } from '../lib/types';
import { degree, mm } from '../lib/format';
import { downloadCsv } from '../lib/export';
import { cutMm, degree, mm } from '../lib/format';
import {
flooringCuts,
groupFlooringCuts,
@ -10,26 +9,99 @@ import {
uncutFlooring,
uncutSkirting,
} from '../lib/cut-list';
import { boardSearchLabel, findBoardMatches } from '../lib/board-tracking';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
import { CutDialog } from './CutDialog';
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;
selectedPiece: string | null;
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, selectedPiece, select }: Props) {
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);
const wholeSkirt = uncutSkirting(skirting);
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
const errors = [...flooring.issues, ...skirting.issues].some(
(issue) => issue.severity === 'error',
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">
@ -39,15 +111,39 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
Each group comes from one board. Select a piece for its cutting diagram.
</p>
</div>
<button
className="button"
onClick={() => downloadCsv(project, flooring, skirting)}
disabled={errors || (!flooring.pieces.length && !skirting.pieces.length)}
>
<Download size={16} />
Export CSV
</button>
</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={200}
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>
@ -57,61 +153,98 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
{wholeFloor.readyOffcuts > 0 &&
` · ${wholeFloor.readyOffcuts} offcuts need no further cuts`}
</p>
{groupFlooringCuts(flooring).map((group) => (
<section
className="stock-cut-group"
data-stock-id={group[0].stockId}
key={group[0].stockId}
aria-label={`Floorboard ${group[0].stockId}`}
>
<h4>
Floorboard {group[0].stockId}
<span>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
</span>
</h4>
<ul className="board-cuts">
{group.map((piece) => (
<li className="board-cut" 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>
<p className="cut-size">
{mm(piece.length)} × {mm(piece.width)} 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>
)}
</div>
</li>
))}
</ul>
</section>
))}
{!floorCuts.length && (
{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)} × {cutMm(piece.width)} 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">
{flooring.pieces.length
? 'No flooring cuts needed.'
: 'Complete the outline and flooring measurements to calculate cuts.'}
{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>
@ -120,69 +253,97 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
Buy {skirting.stockCount} lengths ·{' '}
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} installed uncut
</p>
{groupSkirtingCuts(skirting).map((group) => (
<section
className="stock-cut-group"
data-stock-id={group[0].stockId}
key={group[0].stockId}
aria-label={`Skirting board ${group[0].stockId}`}
>
<h4>
Skirting board {group[0].stockId}
<span>
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
</span>
</h4>
<ul className="board-cuts">
{group.map((piece) => (
<li className="board-cut" 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>
<p className="cut-size">
{mm(piece.length)} 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 {degree(piece.leftMitre)} · Right {degree(piece.rightMitre)}
</p>
</div>
</li>
))}
</ul>
</section>
))}
{!skirtCuts.length && (
{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)} 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 {degree(piece.leftMitre)} · Right {degree(piece.rightMitre)}
</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">
{skirting.pieces.length
? 'No skirting cuts needed.'
: 'Complete the outline and skirting measurements to calculate cuts.'}
{searching
? 'No matching skirting boards.'
: skirting.pieces.length
? 'No skirting cuts needed.'
: 'Complete the outline and skirting measurements to calculate cuts.'}
</p>
)}
</section>
<CutDialog
floor={floorCuts.find((piece) => piece.id === selectedPiece)}
skirt={skirtCuts.find((piece) => piece.id === selectedPiece)}
project={project}
close={() => select(null)}
/>
</section>
);
}