..
This commit is contained in:
parent
9689bfcc18
commit
62a385e4c9
1 changed files with 2649 additions and 809 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue