{{-- Add Project — docs/wireframes-v12/add-project.html (the card at ~lines 104-393: five form sections and the footer). ## Shape (CR-2b, spec §12) ONE `.card`. Inside it, a `.form-section` per heading — each with its `.form-section-title` and the wireframe's own icon — and a `.card-footer` at the bottom. Fields are laid out by v12's `.form-row.form-row-2/-3` grids, not by Bootstrap columns, which is why no field carries a `group-class` any more: the row owns the columns and `.form-group` owns its own margin. The page title is v12's own header wording, "Add New Project", which is longer than the breadcrumb crumb ("Add Project") on the same wireframe page. The old `@section('subtitle')` line is gone: v12's header has no second line and nothing it said is missing from the title. The four upload slots follow v12's own split, which spec §4 now records: the RERA certificate closes Compliance & Legal (~line 307), and the brochure, logo and site plan open Marketing & Media (~lines 318-343) ahead of the two links. They are one pipeline and two sections — the section a zone sits in is a filing decision, and the slot it posts to is not. ## The twin rule (spec §2) This file and projects/edit.blade.php are twins. Every field is written out in both, and neither includes a shared form partial — that is the decision that replaced projects/partials/form.blade.php, deleted once edit.blade.php stopped including it. **A field added, removed or renamed here must be mirrored in edit.blade.php in the same commit.** TwinViewParityTest renders both screens and fails when their `name` sets or their required-attribute sets differ. It does not compare labels, order or validation rules, so those are on whoever edits one of the pair. ## Deliberate differences from the wireframe * "Save as Draft" is not rendered: there is no draft state on the model, and Planning is what the button would have meant (spec §1). v12's footer therefore has three buttons and this one has two — Cancel (`.btn-outline`) then Save Project (`.btn-primary`), in v12's order. * `parking_slots` and `configurations` have no v12 field. The columns stay (spec §3), the controls are gone. * City is a free-text input, not the boilerplate's select2 (§4). Project::CITY_OPTIONS still backs the index filter dropdown. * Project Code is a readonly input with NO `name`, where v12 leaves it named: the server owns the sequence, and a nameless control posts nothing (a readonly one still would). * The Unit Inventory section renders NO row of its own and no way to make one by hand where v12 draws three static editable rows: rows come from an uploaded file, every field in the section is read-only, and there is no add-row button and no per-row delete. Client-directed deviation, spec §11 (CR-1) — it supersedes the add-row/per-row-remove deviation that spec §7 declared. * Its Upload File picker accepts `.csv,.xls,.xlsx` where v12's accepts `.csv,.xlsx`: CR-5 §15.3.5 put all three through SheetJS, and the client's older sheets are saved in the format v12 left out. --}} @extends('layouts.app') @section('title', __('projects.create_page_title')) @section('breadcrumbs') @endsection @section('content') @php $typeOptions = collect(\App\Enums\ProjectType::cases()) ->mapWithKeys(fn (\App\Enums\ProjectType $type): array => [$type->value => $type->label()]) ->all(); // The stored types as the option *values* the select matches on // (form_selected_values() compares strings and has no reason to know // about enums). Null on a fresh form, where $project is a new Project. $selectedTypes = $project->project_types ?->map(fn (\App\Enums\ProjectType $type): string => $type->value) ->all() ?? []; $statusOptions = collect(\App\Enums\ProjectStatus::cases()) ->mapWithKeys(fn (\App\Enums\ProjectStatus $status): array => [$status->value => $status->label()]) ->all(); $amenityOptions = array_combine(\App\Models\Project::AMENITIES, \App\Models\Project::AMENITIES); // 1 / 0 and not true / false: form_input_value() stringifies, and // (string) false is '', which is the placeholder option. $clearanceOptions = [ 1 => __('projects.options.yes'), 0 => __('projects.options.no'), ]; // The accept attribute is derived from the slot the rules are built // from (StoreProjectRequest::uploadRules()), so it cannot disagree // with them. The hint line beside it is a lang string, kept in step by // a test rather than by construction — see lang/en/projects.php. $reraSlot = \App\Enums\ProjectFileSlot::ReraCertificate; $brochureSlot = \App\Enums\ProjectFileSlot::Brochure; $logoSlot = \App\Enums\ProjectFileSlot::Logo; $sitePlanSlot = \App\Enums\ProjectFileSlot::SitePlan; @endphp {{-- Files, so multipart: without the enctype the browser posts filenames and no bytes, and nothing server-side would notice (Laravel's test client sends multipart whatever the form says). --}}
{{ __('projects.sections.basic') }}
{{-- The generated code, shown the way v12 shows it: a readonly `.form-control` with the "Auto-generated" hint under it. It carries NO `name`, which is the whole point and is not the same thing as `readonly`: a readonly input still submits, a nameless one does not. The server owns the sequence (Project::nextCode()), so there is no field here for a client to post. The edit twin's saved-unit rows use the same idiom for the same reason. --}}
{{-- CR-5 §15.3.1 supersedes v12, which draws a single select here: a scheme is routinely more than one type at once. The label keeps the wireframe's singular wording — the client asked for the arity to change, not the caption. --}} {{-- v12 marks Launch Date required; the column is nullable and a project can be recorded before its date is fixed. --}}
{{ __('projects.sections.location') }}
{{-- Free text per v12: the client adds towns faster than we deploy, and the rule is a plain string. --}}
{{ __('projects.sections.structure') }}
{{-- No asterisk on either since CR-5 §15.2.4, where v12 stars both: the client dropped them from the mandatory set, and a blank one now stores NULL rather than 0. --}}
{{-- v12 nests the amenity grid in this section rather than giving it a card of its own. --}}
{{ __('projects.sections.compliance') }}
{{-- Optional since CR-5 §15.2.4 — a scheme is recorded before its RERA number exists. --}}
{{-- Three-state: Yes, No, and the blank placeholder for "not answered". The cast is what keeps a saved No from matching the placeholder — see the boolean-select trap in spec §3. --}}
{{-- The compliance document, with the paperwork it belongs to. The other three uploads are marketing material and live in the next section, as v12 has them. --}}
{{ __('projects.sections.marketing') }}
{{-- Unit Inventory (v12 add-project.html ~356-403). Last section, ahead of the footer, as v12 has it. --}} @php /* * The rows to re-render, straight from old('units') — keys and all. * * StoreProjectRequest drops the rows nobody filled in and * PRESERVES the keys of the ones that are left, and every row error * it raises is keyed to one of those keys (`units.2.unit_number`). * Re-index here and row 2's inline error renders against row 1, so * the loop below never renumbers: it reads the key it was given. * * Non-integer keys are dropped rather than renumbered. The request * rejects the whole array over one, nothing this screen renders can * post one, and rendering an input named `units[first][...]` would * only invite a second rejection. */ $unitRows = array_filter( is_array($oldUnitRows = old('units', [])) ? $oldUnitRows : [], static fn (mixed $row, mixed $key): bool => is_int($key) && is_array($row) // A row nobody filled in is not a row, the same judgement // StoreProjectRequest::isBlankUnitRow() makes. It has to be // made again here: `old` is the input as the browser sent // it, NOT the normalised array the request validated — the // FormRequest is a copy, and its prepareForValidation() // merge never reaches the request the redirect flashes // from. // // KEPT after CR-1 (spec §11) though nothing this screen // renders can post a blank row any more: the rows come // from a file that skips its own blank lines, and there is // no empty row left to submit. It stays because `old` is // whatever the browser sent — a hand-rolled post included — // and a blank row rendered back would be a read-only row // with nothing in it and no way to remove it. && (filled($row['unit_number'] ?? null) || filled($row['carpet_area'] ?? null)), ARRAY_FILTER_USE_BOTH, ); /* * No blank row is appended. CR-1 (spec §11) withdrew the manual * row and the button that made more of them: an upload is the * only way a row reaches this section, so on a fresh GET this list * is empty and the form posts no `units` key at all. * * The keys of the rows that ARE here still matter. They are the * keys the browser posted and the keys every row error is filed * under, gaps and all, which is why the loop below reads the key it * was given rather than renumbering. */ /* * Whether this render is showing rows a rejected submit handed * back — i.e. whether the screen is in the state * nrems-unit-import.js's appendParsedRows() leaves behind, minus * the parse (CR-5 §15.3.6, review finding 1). * * A file is the only thing that can put a row in $unitRows, so * "there are rows" and "a file was imported before this submit * bounced" are the same statement. The summary line below is * server-rendered from it, so the clear-all link is on the screen * for rows the client module did not append and therefore has no * memory of. */ $unitRowsPosted = $unitRows !== []; /* * Rows in error, not errors: two mistakes in one row are one row to * go and fix, and the alert says how many rows to look at. */ $unitRowsInError = collect($errors->keys()) ->filter(static fn (string $key): bool => str_starts_with($key, 'units.')) ->map(static fn (string $key): string => explode('.', $key)[1]) ->unique() ->count(); /* * Every string nrems-unit-import.js can put on the screen, handed * over as data so the module holds no copy of the wording and * nothing it renders is a translation decision made in Javascript. * * JSON_UNESCAPED_UNICODE so the strings survive as themselves: a * — in the attribute is a string no assertion on the lang file * would ever match again. Blade's {{ }} escapes the whole thing for * the attribute, which is the only escaping this needs. */ $unitImportStrings = [ 'unit_number' => __('projects.fields.unit_number'), 'carpet_area' => __('projects.fields.carpet_area'), 'unit_number_placeholder' => __('projects.placeholders.unit_number'), 'carpet_area_placeholder' => __('projects.placeholders.carpet_area'), 'required_field' => __('forms.required_field'), 'from_file' => __('projects.unit_inventory.from_file'), 'summary' => __('projects.unit_inventory.summary'), 'clear' => __('projects.unit_inventory.clear'), 'wrong_type' => __('projects.unit_inventory.error_wrong_type'), 'unparsable' => __('projects.unit_inventory.error_unparsable'), 'parser_missing' => __('projects.unit_inventory.error_parser_missing'), 'too_large' => __('projects.unit_inventory.error_too_large'), 'empty' => __('projects.unit_inventory.error_empty'), 'unreadable' => __('projects.unit_inventory.error_unreadable'), 'too_many' => __('projects.unit_inventory.error_too_many'), 'lines' => __('projects.unit_inventory.error_lines'), 'lines_more' => __('projects.unit_inventory.error_lines_more'), ]; @endphp
{{-- v12 puts the Upload File control on the section-title row, pushed to the right end of it — the one section title on the form that is not just a heading. --}}
{{ __('projects.sections.unit_inventory') }} {{-- A
{{ __('projects.unit_inventory.info') }}
@if ($unitRowsInError > 0) {{-- The summary the row attributes were built for: each row error names its row (":field (row 3)"), so this alert and the inline messages read as one thing. --}} @endif @if ($errors->has('units')) {{-- The array-level error (too many rows, non-integer keys) — about the payload rather than about a row, so it is not one of the counted ones above. `$errors->has()` and not form_error_message(), which would fall back to the `units.*` wildcard and repeat a row's message here. --}} @endif {{-- Filled in by the client module and empty on every server render: the parse-error alert. Nothing the server knows belongs in it — a parse either happened in this browser or did not happen at all. --}} {{-- The "N units from {file}" line and its clear-all link. Written by nrems-unit-import.js's showSummary() the moment a file parses — and, since review finding 1, ALSO rendered here when a rejected submit handed the file's rows back. Without that, the rows came back on a screen whose only control for getting rid of them was hidden and empty: the module never saw them (no `change` event fires on a re-render, so its handlers never ran), so it had not unhidden anything, and the user was left with an import they could not withdraw short of reloading the page. Same ids, same classes, same two child nodes as showSummary() produces, deliberately: the clear-all handler is the module's, it removes rows by [data-unit-source="file"] — which is exactly what the re-rendered rows are marked — and it must not need to know which of the two rendered this line. The one thing that differs is the wording, because the filename never left the browser (see `summary_reposted`). The filename the CLIENT writes here is the one piece of attacker-supplied text on this screen, which is why the module builds it with createTextNode; the server half below interpolates a count and nothing else. --}}
! $unitRowsPosted]) id="nrems_unit_import_summary"> {{ $unitRowsPosted ? __('projects.unit_inventory.summary_reposted', ['count' => count($unitRows)]) : '' }}
@foreach ($unitRows as $unitRowIndex => $unitRow) @php /* * `from_file` is a cosmetic marker (spec §6) and is * treated as one: it has no validation rule, so it * never reaches validated() and the controller * cannot build a unit out of it. Since CR-1 (spec * §11) the ONLY thing it decides is whether the row * wears the "From file" badge. * * Not the `readonly`, which is unconditional * because every field in this section is. And not * the `data-unit-source="file"` on the row below, * which is unconditional for a different reason: a * file is the only thing that can put a row in this * loop, so every row here IS a parsed row as far as * the client module is concerned — clear-all may * drop it, a re-upload may replace it. The rows * that must survive both are the edit twin's * display rows, and they are marked "saved" and * rendered somewhere else entirely. * * So a hand-rolled post arriving without the marker * gets a row that is read-only, clearable, and * simply does not claim to have come from a file. */ $unitRowFromFile = filled($unitRow['from_file'] ?? null); $unitNumberName = 'units['.$unitRowIndex.'][unit_number]'; $unitAreaName = 'units['.$unitRowIndex.'][carpet_area]'; $unitNumberError = form_error_message($unitNumberName); $unitAreaError = form_error_message($unitAreaName); @endphp
{{-- x-form.group directly rather than x-form.input, which is what its docblock recommends for a repeater row — and here for one concrete reason: the asterisk has to render without the HTML `required` attribute. jQuery Validation reads `required` off the element itself, on top of the rules jsvalidation renders, and `units.*` is excluded from those (spec §8), so an attribute here would be read by a validator that has no rule to go with it. The asterisk still belongs: a row that exists needs both cells, which is what the server says too. The `placeholder` on both cells is dead — a placeholder shows on an empty field and a read-only row is never empty — and kept anyway, because nrems-unit-import.js sets the same one and a row it appends must be the same row Blade re-renders after a rejected submit. --}} {!! html()->input('text') ->class('form-control') ->addClassIf($unitNumberError !== null, 'is-invalid') ->id(form_field_id($unitNumberName)) ->name($unitNumberName) ->attributeIfNotNull(form_input_value($unitNumberName), 'value', form_input_value($unitNumberName)) ->attribute('maxlength', '20') ->attribute('placeholder', __('projects.placeholders.unit_number')) ->attribute('readonly') ->attributeIf($unitNumberError !== null, 'aria-invalid', 'true') ->attributeIf($unitNumberError !== null, 'aria-describedby', form_field_id($unitNumberName).'_error') ->render() !!} {!! html()->input('number') ->class('form-control') ->addClassIf($unitAreaError !== null, 'is-invalid') ->id(form_field_id($unitAreaName)) ->name($unitAreaName) ->attributeIfNotNull(form_input_value($unitAreaName), 'value', form_input_value($unitAreaName)) ->attribute('min', '1') ->attribute('step', '0.01') ->attribute('placeholder', __('projects.placeholders.carpet_area')) ->attribute('readonly') ->attributeIf($unitAreaError !== null, 'aria-invalid', 'true') ->attributeIf($unitAreaError !== null, 'aria-describedby', form_field_id($unitAreaName).'_error') ->render() !!}
@if ($unitRowFromFile) {{-- The marker itself, re-rendered so the row survives a second rejection as the read-only row it was. --}} {{ __('projects.unit_inventory.from_file') }} @endif
@endforeach
{{-- v12's card footer: Cancel as the outline button, then the primary action, both right-aligned inside the card's bottom rule. `.card-footer` is already the flex row, so no wrapper of its own. --}}
{{-- The four file slots are excluded from the client rule set: proengsoft cannot express mimes/mimetypes faithfully, and the server rules are what decide. The Unit Inventory repeater is on the same list and for a related reason: proengsoft cannot render rules for rows that do not exist when the page does. Nothing replaces them on the client since CR-1 (spec §11): the rows are read-only, so the only values that can reach the server came out of a file nrems-unit-import.js already checked at parse time, and StoreProjectRequest checks all of it again. --}} @push('scripts') {{-- SheetJS, vendored (CR-5 §15.3.5), and pushed ONLY by the two project form twins: it is the largest asset in the app by an order of magnitude and every other screen would carry it for nothing. `defer` on both scripts keeps them in order, so XLSX is defined by the time the module below runs its DOMContentLoaded handler. --}} {{-- The Unit Inventory file client (spec §6). ES5, and it does nothing at all on a page without #nrems_unit_import — the same shape as the other nrems-*.js modules. Everything it renders lands through .value, createTextNode and setAttribute: a spreadsheet cell is attacker-supplied text and never markup. --}} {{-- The per-slot upload size guard (spec §5). Reads the data-max-kb x-form.file renders from ProjectFileSlot::maxKilobytes() and clears an over-cap pick before it can be posted — which for the 10 MB brochure is what keeps an oversized file from being discarded by PHP's post_max_size and coming back as an unexplained 419. --}} @endpush @endsection