355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|