{{-- Edit Project — docs/wireframes-v12/edit-project.html (the tab strip at ~lines 95-103 and the card below it at ~104-419: five form sections and the footer). ## Shape (CR-2b, spec §12) The one-tab strip in a card of its own, then ONE `.card` holding 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, "Edit Project - {name}". That is where the old `@section('subtitle')` line went — it said the project's name, and the title says it now. The four upload slots follow v12's own split, which spec §4 records: the RERA certificate closes Compliance & Legal (~line 318), and the brochure, logo and site plan open Marketing & Media (~lines 328-355) 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/create.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 when this screen stopped including it. **A field added, removed or renamed here must be mirrored in create.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. ## What this screen has that its twin does not * The one-tab "Project Details" strip above the card. v12 puts it on the edit screen only. * Every control is prefilled from $project, and each filled upload slot shows the stored file: its original client filename (escaped — §5 stores it verbatim), a download link, and the replace copy. * PUT rather than POST, and UpdateProjectRequest as the rule source — which inherits StoreProjectRequest whole, exclusions included. * A third breadcrumb crumb: the project's own show page sits between the listing and this screen. * "Save Changes" on the footer button where create says "Save Project", which is v12's own wording on the two pages. The footer is otherwise identical: Cancel (`.btn-outline`) then the primary action. v12's add screen has a third button between them, "Save as Draft", which neither twin renders (see the deliberate-differences list below). * The Unit Inventory section carries three things the create twin's does not, all of them about units that already exist: the count line, the units themselves — since CR-1 (spec §11) — rendered inside #unitInventoryRows as read-only DISPLAY rows above whatever an uploaded file adds, and the replace warning that CR-5 §15.3.6 shows beside them the moment a file parses. Those rows have no `name` attribute at all, so they post nothing and TwinViewParityTest still sees identical name sets across the pair; the warning is a hidden div for the same reason it is not on create — the create twin has no saved units to replace. This is the whole of the twin delta in that section: the toolbar, the info line, the alerts, the summary and the re-rendered file rows are mirrored in create.blade.php exactly. ## Deliberate differences from the wireframe * `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) — and on this screen the code is immutable besides (it is absent from the rule set). * 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.edit_title_for', ['name' => $project->name])) @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). This is the twin that actually pre-selects a set. $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; // The current-file row needs both halves (x-form.file renders it only // when it has them), and the download route is worth offering only for // a slot that actually holds a file — an empty one 404s by design. // // The `mixed` is deliberate, and the asymmetry with $storedUrl's // ?string is too: getAttribute() returns mixed, the component treats // the name as display data and escapes it, and narrowing here would // mean a cast that says more about the column than this file knows. $storedName = static fn (\App\Enums\ProjectFileSlot $slot): mixed => $project->getAttribute($slot->originalNameColumn()); $storedUrl = static fn (\App\Enums\ProjectFileSlot $slot): ?string => filled($project->getAttribute($slot->pathColumn())) ? route('projects.files.download', [$project, $slot->value]) : null; @endphp {{-- v12's single "Project Details" tab. One tab is chrome, not navigation: there is no second panel to switch to, so it renders as a static strip rather than as tab controls that would announce a tablist and do nothing — and, unlike the two-tab strip on the show screen, it is not wired to nrems-tabs.js. `.card > .tabs` carries the 24px side padding v12 writes inline; the `px-6` that used to be here was Metronic's and goes with it. --}}
{{ __('projects.tabs.details') }}
{{-- 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 saved-unit rows below 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. This is the screen the trap bites on: without the cast a saved No renders as "Select" and the next save clears it. --}}
{{-- 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 edit-project.html ~369-418). 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 (CR-5 §15.3.6, review finding 1). * * A file is the only thing that can put a row in $unitRows, so on * this twin "there are rows" means "the pending save is a REPLACE" * — the same conclusion nrems-unit-import.js draws the instant a * file parses, reached from the flashed input instead of from a * parse. * * It has to be reached here at all because the module cannot reach * it on a re-render: its whole CR-5 §15.3.6 behaviour hangs off the * file input's `change` event, and a `change` event does not fire * on a page that has just been drawn. So the state below is * rendered by the server in the exact shape appendParsedRows() * would have left it — saved rows hidden, warning shown, summary * and clear-all present — and the module's clear-all handler then * undoes it without knowing which of the two put it there. * * The bug this closes was not cosmetic: without it the screen * showed the saved units AND the file's rows, side by side, with * no warning and no way back. Correcting the typo that caused the * rejection and saving again silently deleted every unit shown * above (CR-5 §15.4 replaces on any posted rows). */ $unitRowsPosted = $unitRows !== []; /* * ...and whether that replace would actually destroy anything. * * Mirrors setSavedRowsHidden()'s own condition * (`hidden && rows.length > 0`): a project with no units has * nothing to be warned about losing, and a warning that fires * anyway is the kind of copy people learn to stop reading. The * rows are hidden on the wider condition — hiding none of nothing * is free, and it keeps the two flags' meanings separate. */ $unitReplacePending = $unitRowsPosted && $project->units->isNotEmpty(); /* * 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') }}
{{-- Edit only. The screen SHOWS the units the project already has, read-only, in the rows container below (CR-1, spec §11), and this line counts them. What those rows MEAN changed with CR-5 §15.4: a submit that carries file rows no longer adds to them, it replaces them — every one of the units counted here is deleted and the file's rows are inserted in their place. The count is still a statement about now, which is why it is not reworded; the warning below is what says what saving will do, and nrems-unit-import.js shows it the moment a file parses. Rendered at zero as well as at three — "0 units" is the answer to the question the line exists to answer, and an absent line is not. The line used to end in a "Manage units" link into the standalone Unit Inventory screen. CR-5 §15.1 removed that screen and CR-5 §15.3.4 removed the link with it: units are not editable anywhere, so the count is now the whole of what this line has to say. `$project->units` and not `->units()->count()`: the collection is eager-loaded by ProjectController::edit for the display rows, so counting it here costs no query. --}}
{{ __('projects.unit_inventory.existing', ['count' => $project->units->count()]) }}
{{-- The replace warning (CR-5 §15.3.6). Server-rendered so the wording stays in lang/en/projects.php with everything else on the screen, and hidden until nrems-unit-import.js has a parsed file to show it for — before that there is nothing to replace anything with, and a standing warning about a destructive save on a screen that is not about to make one is the kind of copy people learn to stop reading. `alert-warning` and not `alert-danger`: the red band above it is for a submit that FAILED, and two reds would say the same thing about two unrelated states. This one is a consequence the user is choosing, not an error. Rendered even when the project has no units — the module only unhides it when there are saved rows to hide, so the view does not need the second condition and the two twins' edit-side markup does not change shape with the data. `d-none` is unconditional on a fresh GET and dropped on the re-render after a rejected submit (review finding 1): the rows are back on the screen, so the replace they imply is back too, and the warning has to come back with them. $unitReplacePending is setSavedRowsHidden()'s condition spelled out in PHP. --}}
! $unitReplacePending]) id="nrems_unit_import_replace_note" role="alert"> {{ __('projects.unit_inventory.replace_note') }}
@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. On this twin the link is the way OUT of a pending replace: clicking it drops the re-rendered rows, brings the saved units back and takes the warning down, because the module's handler removes rows by [data-unit-source="file"] and calls setSavedRowsHidden(false) — none of which needs to know whether Blade or the module drew what it is undoing. Without this line the rows came back with the handler's own trigger hidden and empty, and the only way to withdraw the import was to reload the page. Same ids, classes and child nodes as showSummary() produces. The one difference is the wording: the picker has no `name`, so 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)]) : '' }}
{{-- Edit only, and the reason this container is not empty on a fresh GET here the way it is on the create twin: the units the project already holds, displayed read-only (spec §11). **These rows carry no `name`, and that is load-bearing — more so since CR-5 §15.4 than before it.** A submit that carries unit rows REPLACES the project's whole inventory, so a display row that posted itself would make every save of this form a replace: open the screen, change the promoter's name, save, and the units would be deleted and re-inserted under new ids and a new importer. Nameless inputs are how the screen shows a stored unit without the form claiming to write it — `readonly` alone would not do it, since a readonly input still submits. Nothing here is a field, so TwinViewParityTest sees no name to match against the create twin, which is the same statement from the other side. Ordered by unit number ascending, the same order the project's show page lists them in. The order is set in ProjectController::edit's eager load, not here. Parsed file rows append AFTER these, because nrems-unit-import.js appends to this container and its removeFileRows() only ever touches [data-unit-source="file"] — a stored unit is not one of those and cannot be cleared by the clear-all link. --}} @foreach ($project->units as $savedUnit) @php $savedUnitId = 'unit_saved_'.$savedUnit->getKey(); @endphp {{-- `d-none` when a rejected submit handed file rows back (review finding 1). Hidden and not dropped, exactly as setSavedRowsHidden(true) hides them: the clear-all link has to be able to put the screen back the way the server would have drawn it with no pending import, and rows that were never rendered cannot come back. --}}
$unitRowsPosted]) data-unit-row data-unit-source="saved">
{{-- The two kinds of row look alike on purpose and still have to be tellable apart: this one is already in the database, a "From file" row is not saved until the form is. The wireframe's grey `badge-secondary` is spoken for by that other badge, and v12's palette has no neutral second grey, so the stored row takes the green one — the only pair of the defined tones that reads as "kept" against "pending" rather than as two arbitrary colours. The missing required asterisk on the labels above says the same thing again: nothing here is a field. --}} {{ __('projects.unit_inventory.saved') }}
@endforeach @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. UpdateProjectRequest inherits both the rules and the exclusion list from StoreProjectRequest, which is why the list is named there and read here. 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