This commit is contained in:
Andras Schmelczer 2026-09-11 16:58:56 +01:00
commit 9689bfcc18

View file

@ -5,6 +5,7 @@ import { downloadCsv } from '../lib/export';
import {
flooringCuts,
groupFlooringCuts,
groupSkirtingCuts,
skirtingCuts,
uncutFlooring,
uncutSkirting,
@ -12,32 +13,6 @@ import {
import { FlooringPreview, SkirtingPreview } from './CutPreview';
import { CutDialog } from './CutDialog';
function StockAllocations({
title,
items,
}: {
title: string;
items: { id: string; pieces: string[]; size: string; remainder: string }[];
}) {
if (!items.length) return null;
return (
<details className="stock-allocations">
<summary>{title}</summary>
<ul className="allocation-list">
{items.map((item) => (
<li key={item.id} className="stock-card">
<strong>
{item.id} <span>· {item.size}</span>
</strong>
<span>{item.pieces.join(' + ')}</span>
<small>{item.remainder}</small>
</li>
))}
</ul>
</details>
);
}
interface Props {
project: Project;
flooring: FlooringPlan;
@ -49,29 +24,23 @@ interface Props {
export function CutList({ project, flooring, skirting, selectedPiece, select }: Props) {
const floorCuts = flooringCuts(flooring),
skirtCuts = skirtingCuts(skirting);
const floorGroups = groupFlooringCuts(flooring);
const floorIds = new Set(floorCuts.map((piece) => piece.id));
const skirtIds = new Set(skirtCuts.map((piece) => piece.id));
const wholeFloor = uncutFlooring(flooring, project.flooring);
const wholeSkirt = uncutSkirting(skirting);
const selectedFloor = floorCuts.find((piece) => piece.id === selectedPiece);
const selectedSkirt = skirtCuts.find((piece) => piece.id === selectedPiece);
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
const errors = [...flooring.issues, ...skirting.issues].some(
(issue) => issue.severity === 'error',
);
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">
Cut pieces and offcut placements. All measurements are in millimetres.
Each group comes from one board. Select a piece for its cutting diagram.
</p>
</div>
<button
className="button secondary small"
className="button"
onClick={() => downloadCsv(project, flooring, skirting)}
disabled={errors || (!flooring.pieces.length && !skirting.pieces.length)}
>
@ -79,186 +48,138 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
Export CSV
</button>
</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 · {flooring.pieces.length} installed pieces ·{' '}
{wholeFloor.wholeBoards} full boards laid uncut
Buy {flooring.boardCount} boards · {wholeFloor.wholeBoards} laid uncut
{wholeFloor.readyOffcuts > 0 &&
` · ${wholeFloor.readyOffcuts} offcuts need no further cuts`}
.
</p>
{floorCuts.length ? (
<div className="table-scroll">
<table className="cut-table">
<caption className="sr-only">Flooring cuts and offcut placements</caption>
<thead>
<tr>
<th scope="col">Pieces</th>
<th scope="col">Qty</th>
<th scope="col">Cut size · mm</th>
<th scope="col">Room</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
{floorGroups.map((group) => {
const first = group[0];
return (
<tr
key={first.id}
className={`flooring-batch ${group.some((piece) => piece.id === selectedPiece) ? 'selected' : ''}`}
>
<td data-label="Pieces" className="batch-codes">
<div className="piece-chips">
{group.map((piece) => (
<button
key={piece.id}
className={`piece-link piece-chip cut-preview-button flooring-preview-button ${piece.id === selectedPiece ? 'selected' : ''}`}
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>
</button>
))}
</div>
</td>
<td data-label="Qty" className="batch-qty numeric">
{group.length}
</td>
<td data-label="Cut size · mm" className="batch-size numeric">
{mm(first.length)} × {mm(first.width)}
</td>
<td data-label="Room" className="batch-room">
{roomName(first.roomId)}
</td>
<td data-label="Action" className="batch-action">
{!first.cut
? 'Use offcut · no new cut'
: first.shaped
? 'Shaped cut'
: 'Rectangular cut'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
{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 && (
<p className="empty-state">
{flooring.pieces.length
? 'No flooring cuts needed.'
: 'Complete the outline and flooring measurements to calculate cuts.'}
</p>
)}
<StockAllocations
title="Source boards for flooring cuts"
items={flooring.stocks
.filter((stock) => stock.pieceIds.some((id) => floorIds.has(id)))
.map((stock) => ({
id: stock.id,
pieces: stock.pieceIds.filter((id) => floorIds.has(id)),
size: `${mm(project.flooring.length)} × ${mm(project.flooring.width)} mm`,
remainder: `${mm(stock.remaining)} mm remaining`,
}))}
/>
</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 · {skirting.pieces.length} installed pieces ·{' '}
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} full lengths installed uncut.
Buy {skirting.stockCount} lengths ·{' '}
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} installed uncut
</p>
<p className="helper">
Lengths use the longest point. Mitres are saw settings from square; left and right follow
the wall direction.
</p>
{skirtCuts.length ? (
<div className="table-scroll">
<table className="cut-table">
<caption className="sr-only">Skirting pieces requiring cuts</caption>
<thead>
<tr>
<th scope="col">Piece</th>
<th scope="col">Room / wall</th>
<th scope="col">Length · mm</th>
<th scope="col">Left / right</th>
<th scope="col">Source</th>
</tr>
</thead>
<tbody>
{skirtCuts.map((piece) => (
<tr key={piece.id} className={selectedPiece === piece.id ? 'selected' : ''}>
<td data-label="Piece">
<button
className="piece-link 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>
</button>
</td>
<td data-label="Room / wall">
{roomName(piece.roomId)}
<small>Wall {piece.wallIndex + 1}</small>
</td>
<td data-label="Length · mm" className="numeric">
{mm(piece.length)}
<small>{mm(piece.backLength)} at wall</small>
</td>
<td data-label="Left / right">
{degree(piece.leftMitre)} / {degree(piece.rightMitre)}
<small>
{piece.leftType} / {piece.rightType}
</small>
</td>
<td data-label="Source">{piece.stockId}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
{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 && (
<p className="empty-state">
{skirting.pieces.length
? 'No skirting cuts needed.'
: 'Complete the outline and skirting measurements to calculate cuts.'}
</p>
)}
<StockAllocations
title="Source lengths for skirting cuts"
items={skirting.stocks
.filter((stock) => stock.pieceIds.some((id) => skirtIds.has(id)))
.map((stock) => ({
id: stock.id,
pieces: stock.pieceIds.filter((id) => skirtIds.has(id)),
size: `${mm(stock.length)} mm`,
remainder: `${mm(stock.waste)} mm waste including kerf`,
}))}
/>
</section>
<CutDialog
floor={selectedFloor}
skirt={selectedSkirt}
floor={floorCuts.find((piece) => piece.id === selectedPiece)}
skirt={skirtCuts.find((piece) => piece.id === selectedPiece)}
project={project}
close={() => select(null)}
/>