Follow this skill: --- name: minimal-ux-flow description: Understand what an app is and how its screens fit the product, then cut its flow down to the fewest steps and screens that still do the user's one job, and make the screens look clean to support it. Use when an org or project wants a minimal or lean app, when defining the flow of a new app before building it, or when asked to simplify, distill, streamline or strip down a flow or its screens. First produces a short model of the app for the user to agree; then a flow card for the user to approve. The visual touch-up goes to impeccable. metadata: usage-tier: junior --- # Minimal UX flow You first understand the app. Then you cut its flow to the barebones, prove it with a count, and show it. The supervisor approves each stage against the checks in this skill, and the user judges the finished app. Building is other work. Cutting before understanding gives wrong cuts. Example: shortening a form's fields, when the system already knows those values and the user should not type them at all. A count of taps says nothing until you know what each screen is for. Why the cutting is built this way: every extra thing on a screen "competes with the relevant units of information and diminishes their relative visibility" (NN/g, heuristic 8). But minimal done badly hides what people need: hidden navigation made content more than 20% harder to find, and weak "you can click this" cues cost 22% more time. So this skill cuts steps and words. It never cuts the cues that tell a person what to do next. ## Before you change a project If the app already exists and is being redone, start from `app-overhaul`: it sends you back here for the method. Look at the relevant features before you make a change. If you add or change a screen, open the other screens that show the same kind of thing (another table, another form, the sidebar) and use the same component and the same pattern. Do not build a new piece when one exists, and do not add a concept (a field, a picker, a column) that the data model and the other screens do not have. Why: on 2026-10-13 a Jobs screen was built with its own table pieces and a hardcoded car picker, without looking at how the other table screens work. The user said: "You really need to look at relevant features when making changes to a project." ## Stage 1: the model (before any cut) 1. **Build the frame yourself, from the system the app is part of.** Who is in the system, who writes what, what happens to the things in it over time. Describing the product is your job, not the user's: never ask them to describe it and never wait for them to. How the real-world process works (how the thing is born, who asks whom, what is said, what gets typed or chosen) comes from real-world knowledge of that business, reasoned out as a concrete worked example (what starts it, what is said or sent, what gets written down) by the method in `conceptual-modeling` (Exercise); never from the codebase, and never by asking the user. What the app and its neighbours do today comes from the evidence: the code, the backend contract, the README, and who else writes or reads the same data (sibling repos, the dashboard or server side, the org workboard). Finding the other side is a search, not a glance: for each thing the app stores, take its collection name and its most distinctive field names and ids, and grep for them across the sibling repos (the dashboard, the server, the workboard docs). Read what you hit: the create, edit and list screens, the `INTENT.md` and README files, the sample data. Those tell you who creates the thing, who completes it, who reads it, and what the other side links it to (customer, vehicle, job). Stopping at the first module you find is not enough. Anything the user has already said (this chat, project memory, earlier sessions in `~/.claude/projects//*.jsonl`) overrides what you worked out. Where the evidence does not say, write "can't confirm"; do not make up a frame. Facts the user gives you are limits you work inside; they do not widen the job. If new facts suggest a bigger restructure, report it as a finding, never as your plan. **Then write the data model, before any screen is read or cut.** It is the formal note of the scenario: every thing in the real world (a business, a service, a booking), each of its fields, who produces the field (a person, a device, a rule), and the form it really takes, with one real example. Next to each field, say what the system has today: present, missing (the real world produces it but the system has no place for it, such as a photo of the work), typed twice, or written by nothing (only seed data fills it). Propose the changes, and settle them with the user. Screens can only show what the model holds, so a cut made before the model is settled misses things (images, on 2026-10-15). Store it as `data-model.md` next to the flow card. (User, 2026-10-15: "we forgot to do a preliminary phase of work which is to decide about the underlining data model, which is the formal way to annotate our Understanding of the flows of the real world behaviors.") 2. **Read every screen in the app, not just the one you were pointed at.** A screen only makes sense next to all the others, so you cannot judge one flow before you know how all screens relate. How: - Start from the route table or navigation setup. List every registered screen. That list is your checklist, and you finish it. Say "read N of N" in the model. Do not show a model built on part of the app: a partial reading gets the parts wrong. Finish the list first. - For each screen, write down: what it shows, what it reads and stores (the call and the shape of the data), where it can go next (every navigation call in it), and what leads into it. - Build the screen-to-screen map from those: entry points, dead ends, screens reachable from one place only, loops, and which screens touch the same record or the same data collection. - Group the screens by the data they touch and by the user's own words. Each group is a service. Compare the groups with the menu or home screen: where the menu groups things differently from the data, that is a finding. - Check each thing both ways: who writes it and who reads it. Something written and never read, or read and never written, is a finding. - Walk the live app too, at phone width, for what the code cannot tell you (what the screen really shows, how it looks). - You may split the reading of a large app across helpers, one per group. You still own the map and the claims. 3. **Trace every screen to its end.** From the screen, through its state, to the call that stores or reads data, and what that data looks like. Walking the screen is not enough, and a screen you have not traced is a screen you may not describe. Name a screen by what its code does, never by a field or type name: a `status` field in a data contract does not make a screen a status update. When you have not traced something, say so. 4. **Map each screen onto the model.** First, always, fill the origin table: For every text, number and choice on every screen, one row: what in the real flow produces it (a person, a device, a schedule, a rule), whether it is picked from a large list, typed free, measured or set by the system, and one real example in the form it really takes. The real-flow part comes from the scenario you wrote in step 1 (`conceptual-modeling`, Exercise), never from the code. Each row names the evidence for its producer (a field on the office's own form, a document, the user's words); an assumed producer does not count. "None found" means the text is cut, and you propose that cut first, before relabelling or rewording the text. A text with no real producer is invented: cut it or fix the data. A short label picked from a large list and a free-text detail under it add to each other and never repeat. Seed and demo data are written by the same table, each field as its real producer would fill it. The table goes in the flow card (step 14). Then, for each screen: For each one: which operation it is (create, read, update, delete, or a status change), on which thing, and whether it belongs to the main job or to another job. Screens for another job are another flow; leave them out, say so, and say which group they belong to. Look for: - a thing that nothing lists or shows; - two screens that each hold half of one thing, sharing no record; - a field the user types that the system already knows or measures; - the app doing a job that belongs to another part of the system; - a "finish" that stores nothing; - two texts on a screen that say the same thing in other words. Always fill the origin table (below) for every text, number and choice on every screen; this is how they are found. 5. **Compress to a few named claims, with honest blanks.** Say the whole app in a handful of sentences, such as: "N services in M screens. A and B are not part of the main flow. The main flow has X that creates, Y that updates, W that lists." A blank (nothing lists the items) is a finding, not a gap to hide. A dozen facts and a dozen steps is a dump, not a model. Write it in this shape, so a person who knows nothing can retell the app after one read: - **What it is,** in one or two sentences: who uses it, and what system it is a part of. - **The intended flow as one person's day,** numbered, in role words only, with no screen names. This is the flow as it should be, taken from the frame in step 1, never from the screens that exist today. Start from what the system around the app needs: what reaches the person, what they do with the customer, and what must flow back to the other side. If every step you wrote is a tool the app already has (count, scan, upload, photograph), you have copied the menu, not the flow: redo it from the other side of the data. The day opens with what reaches the person from the other side and ends with what reaches the other side, with the work in between; the first and last step each stay in the day even when the app has nothing for them, and their "Today:" line says "doesn't exist". Do not add a step the system does not need just to fill the day: every step is something the other side needs or something the person does for it. Steps the system needs but the app does not have stay in the day; their "Today:" line says "doesn't exist". Mark anything the evidence does not settle as your own reading. - **Under each step, one line "Today:"** saying what exists for that step, naming the screens, or "doesn't exist". This is how the screens relate to each other: through the step they serve, in the same sentence. Never split it into "A does X" and a separate "how they relate". - **The parts that do not belong to the flow,** one line each, saying what they do to the data. - **What a newcomer should take from it:** which steps exist only as unconnected pieces and which are missing. - The answer is these five parts and nothing else, small enough to read in one screen (about 350 words, around five steps in the day, one or two sentences per "Today:" line). Do not add a findings list, a table of services, or a per-screen walk-through: the findings are the "Today:" lines, the parts outside the flow, and the newcomer sentences. The only thing after them is the step 7 question, if there is one. - The day holds only the steps of the flow the frame names. A tool that belongs to another job is not a step, not even a last one "because the menu offers it": it goes in "the parts that do not belong" and nowhere else. - Each screen or tool appears in exactly one place: as a step in the day, or in "the parts that do not belong", never both. Decide first which tools carry the flow, then write the day with them and list only the rest as outside it. Do not name a "main flow" that contradicts the day. - Every claim rests on the code or the user's words. Say "can't confirm" where it doesn't. Use no vague phrases ("same world", "related"). When the user corrects one thing, change only that thing and keep the rest. 6. **Fix the words.** Use the user's words and the codebase's. One word per thing, and once it is chosen it stays in every file, screen and message. Flag every clash: one thing with several names, or several things with one name. Give the main flow a short name and keep using it. 7. **Show the model and settle it with the user before moving on.** Show only the five parts of step 5, with one status line above them ("read N of N screens; not traced: ..."). The per-screen traces, the screen map and the word notes of steps 2, 3 and 6 are your working notes: they stay out of the answer, except that the words you chose are used in it and, if one clash would mislead a reader, it gets one short sentence in the newcomer part. Ask only about decisions that are the user's to make (for example whether the app is meant to do another system's job). Do not ask them to choose fields, steps or screens yet. ## Stage 2: the cut 8. **Write the one job.** One sentence: who comes, what they want done, and what "done" looks like for them. It is one thread of the model. Two jobs are two flows; do them one at a time. 9. **Write the flow as it is.** List every step from entry to done, numbered, and count the steps and the screens. A flow that exists: walk it in the live app with the `browser` command (`~/projects/workstation/browser/README.md`) at phone width, and count what you did, not what you remember. A new app: draft the obvious flow fast. Do not polish it. 10. **Cut, in rounds.** Keep a step only if it is something only the user can do: give information the system cannot know, or make a decision only they can make. Every other step goes by one of these: - **remove:** the job does not need it; - **pre-fill:** set a smart default, or reuse what the user already gave ([NN/g](https://www.nngroup.com/articles/minimize-cognitive-load/)); - **detect:** the system finds it itself, including anything the model says another part of the system already knows; - **merge:** two steps become one screen, or one field; - **defer:** it is needed later or rarely. Put it behind a clearly labelled control, two levels deep at most ([NN/g](https://www.nngroup.com/articles/progressive-disclosure/)). Round 1 cuts steps. Round 2 cuts what is on each remaining screen (text, fields, controls) the same way, leaving one main action per screen and asking of every instruction whether the screen is clear without it. Ask the same of every element (a text, a control, an icon, a badge, a row): what is its job for the person, and at what moment do they need it? An element with no job is cut. An element that says what another one already says (in other words, as an icon, or in a second place) is a repeat and is cut, unless it serves another reader or another moment (a colour on top of a state's word is for the person who cannot see the colour). Round 3 looks at the steps again, now that the screens are lighter, and merges screens. Then it groups and orders each screen. Every element lives with the thing it describes: the client's own details in the client's card, the kind of work next to the description of the work, and what describes the whole job (its state) at page level, in the header. Elements are never grouped because they look alike (small chips, rows of text) or because they come from the same field: tags beside the client's name say 'this client is electronics'; a vehicle that belongs to the job does not go in the client's card. The order follows the person's day: what they need first comes first, what is optional (photos) comes last, the same in every state. 'Should X be here or there?' is a question about what the person is doing when they need X, answered from the scenario of step 1, never from a rule, a convention or the existing screens. Stop each page and component at exactly one statement of what it is about: cut the repeats, and add a name where there is none (step 11 counts it). Stop when a round cuts nothing. Do not cut to have cut something. The rounds follow the [5-pass reduction](https://dreamwalk.com.au/app-design/5-pass-reduction-wireframing), a practitioner's method, not research. 11. **Check what you must not have cut.** This is the one checklist for every screen of an app, kept only here. The supervisor walks it on every screen of the app, not only the screens that changed, before each push or release: one row per screen per check, with the phone-width screenshot. A failed row is a rejection that quotes the check. A screen that matches the other screens still fails a check: the screens already built are not the standard, this list is. - Every page says what it is about exactly once, in its h1: one title in the user's own word, 24 px on every screen, wrapping to a second line when long and never cut with dots, sitting 16 px under the header, with no more than one control row above it. A page with no h1 fails (except a page whose subject has no name of its own: a job, which the office describes in one text and never titles; no title is invented for it, because an invented title repeats the content, and it opens with the job's state in the header; User, 2026-10-15), and so does any other element that says the same thing again. Why: Material says not to truncate a headline, and NN/g says that on a phone breadcrumbs 'eat up valuable space'; a title under a stack of bands (bar, crumb row, rule) looks like a web page dropped into an app. Every component says what it is about exactly once too, as a short name (0 and 2 or more are both failures). Write the count per screen in the card. Why: the person needs to know at a glance what this page is, and a page named twice is noise. (User, 2026-10-14: "it's not ok that almost no page has a main title".) - The basic header is part of minimal design, not an extra and not a separate rule: one quiet control row on the white page, with no band and no breadcrumbs (a large empty stretch in that row is a layout smell: either something the person needs at that moment belongs there, such as the job's state in the middle of the job page's header, or the row is too big): Back with its word on every screen but the tab roots (My jobs, Leads) and login (Back says where you are going, the title says where you are), and the logo on the tab roots and login. The app name appears only on the login screen, where nothing else says where you are. A screen with a stack of bars, or a crumb row, fails this check. (User, 2026-10-14, asking whether the app name needs to show all the time; tab roots, User, 2026-10-19: with bottom tabs Leads is a top screen like My jobs, so it gets the logo, not a Back with nothing to go back to.) - No grey backgrounds. The page, the header, the bars and the cards are white, and things are set apart by thin rules and space, not by a grey fill. Black and white only; a specific element may keep its own colour (a state pill, a part's badge, a photo), but no background is grey. (User, 2026-10-22: "remove the grayish background thing: Make it purely black n white. specific elements can retain their colors but pls no gray bg", and "add this as a rule to betec: No gray backgrounds".) - When the thing a page is about has a state (a job: new, in progress, done), the state is shown in words where the person sees it first: beside the h1 as the same small labelled pill the list uses, not a bigger one (design systems put one-word badges level with the title, centred on it, and keep them from outweighing it); on a page with no title (the job page), as one bold word in the state's colour in the middle of the header, since there the state is the first thing the person needs (User, 2026-10-15: "bold and prominent"). Why: the state decides what the person does next. (User, 2026-10-14: "I'd like the state ... to be upfront and clear".) - Account actions (who is logged in, log out) sit behind a cog icon-button in the header that opens a Settings screen, not as a loose text link. (User, 2026-10-14: "I wanted a cog".) - An add button (+) sits at the far end of the title row of the list it adds to, level with the h1, never in the header: the person looks for "add" where the list is named, and the header holds only what every screen has (Back or the logo, the cog). (User, 2026-10-19: "the "+" button needs to be where it refers to, it needs to be in the row of the title "העבודות שלי" same row, on the far end".) - No list row shows an id (catalog id, SKU, database id, UUID). A row shows a name, and an icon or a product image; a part with no image gets a plain icon. Ids stay in the data. Why: the person recognises a part by its name or picture; the id tells them nothing they can use. (User, 2026-10-14: "Never render id in lists.") - A list row shows at most two lines of text: the title on line 1 and one subtitle line on line 2, with at most one short item at the end (an amount, a count or a state pill) and the chevron. Everything else about the thing (more states, tags, a list of its parts) goes on its own page or form, not on the row. Why: rows of three to six lines stop reading as a list, and the person scans line 1 down the list. (User, 2026-10-20, on the owner's lists: "optimized towards 2 rows of elements (not 3 or 4 which is currently plagued with that)".) - No page has two visible buttons that compete for the same intent (for example "stop the timer" and "continue" when both mean "I'm done working"). Count by what the person means, not by the code each button runs. Both stay only with the user's explicit approval. Why: the person has to work out which of the two to press, and one ends up greyed out or pointless. (User, 2026-10-14.) - On a phone, every screen except login and the top screens (the main menu, or each tab root) has a Back button at the top, one screen back. Why: the person needs a one-tap way out, and it carries the word, not only an arrow. (User, 2026-10-14.) - A name on screen is usually clickable: it is a piece of the data model, and tapping it shows more about that thing or lets the person act on it. Plain text only when there is nothing more to show or do. (User, 2026-10-14.) - No free text field for a choice from a large, finite set of options (a part from the catalog, a car, a client): use an autocomplete over that set; a small set is a select or buttons. Why: a text field and a list are incompatible, so typing lets in values that are not in the list; this is a case of `conceptual-modeling`. (User, 2026-10-14: "choosing between a large, finite set of options = autocomplete".) - Every control has a label, or an icon that is obvious without one. A text control whose meaning an icon says without a word (settings, call, navigate, add) is an icon button, and gives its row back; where an icon is not self-explanatory, or the user decided the word stays (Back), the word stays. - Anything clickable looks clickable. - Spacing and sizing follow a scale, not taste. Every size, gap and padding is 2 px, 4 px, or a multiple of 8 (8, 16, 24, 32, 48, 64 and so on), and the big blocks (rows, buttons, bars) sit on 16, 32, 48 or 64; text sizes are even numbers. A value off the scale (12, 20, 28, 44, 77 and so on) fails. Fixed measures: a list row is 64 px high; a title and its subtitle are 2 or 4 px apart; a text and an icon in the same row are 4 px apart (horizontal); a button is at most 48 px high (a tappable row is a list row, not a button, so it is 64 px). Photo tiles in a grid are not rows. Measure the rendered screen, not the code: read the computed sizes in the browser and list every value off the scale. Why: chaos in spacing makes the screens look unrelated; one scale makes them look like one app. (User, 2026-10-14.) - On a phone, navigation of four or fewer top-level places is visible, not behind a menu icon ([NN/g](https://www.nngroup.com/articles/hamburger-menus/): hidden navigation was more than 20% harder to discover on both desktop and mobile, and at least 39% slower on desktop). - The user still has what they need to decide, and to trust the result. - Errors, empty data and the Back button still exist. - Nothing moves when data arrives. While a screen loads, it holds the place of what is coming with placeholder rows the height of the real rows, so nothing below or beside them jumps when the data comes. A spinner, a blank, or a box of another height fails. Check it with two screenshots, one while loading and one after: everything outside the list is in the same place in both. Why: a button that jumps as the person reaches for it gets missed or mis-tapped. (User, 2026-10-20: "content shifting is strictly prohibited".) - A control that switches one list between views (open and closed, upcoming and past) is a real tab bar above the list: each tab names its view, the current one is marked, and the list shows only that view. A button that swaps the list, or one list with every view mixed in, fails. (User, 2026-10-20: "the 'closed/open' button is pretending to be a tab nav".) - Every list is built for 5,000 rows or more, on a phone above all, whatever today's data holds: it is fetched in pages, only the rows on screen are drawn, more load as the person scrolls, and a search over it asks the server. A list that fetches or draws every row fails. (User, 2026-10-20: "you can never assume a list is of size 5-10, always assume any list is 5k long or more".) - When a screen offers one action of its own (the + of a list stays in its title row), that action floats as the screen's call to action, always in reach, and the list leaves room at its end so the button never covers a row or its buttons. With more than one such action, choose a grouping for them instead of floating them all. (User, 2026-10-20.) - A first-time user can name each screen's main action in a few seconds (a rule of thumb from a practitioner guide, not research). 12. **Benchmark, only if research exists.** If a `functional-research` walkthrough of this job exists, put the fewest steps seen in any product next to yours, and beat it or say why not. If there is none, step 10's test is the benchmark. Do not research competitors here. 13. **Make the screens look right.** Only once the cut is settled. One look for the whole app: the same button, the spacing and sizes on the step 11 scale, type sizes and colors everywhere, in the agreed words, built from few plain parts that carry over to other platforms. Never decide the look by your own taste. Every change to how a screen looks goes through the design research pass first, every time, for the screens it touches (the whole app, the header and title, a list, and so on); it is how the look is chosen. The pass is one researcher agent that edits no app code and writes to `docs/design/-/`: (1) `research.md`: why the current screens look the way they do, as observable causes quoted from screenshots (no vague taste words), and how experts solve it, from primary sources (Apple HIG, Material 3, NN/g, WCAG) with the lines quoted and the links, no competitor products; (2) three genuinely different pitches, each with phone-width mockups of the real screens built as static pages and looked at before keeping, a step 11 table, a build cost, and a plain line for any step 11 rule it would change; (3) `recommendation.md`: one pitch, why, what was rejected, and the code changes in order. You then build the recommendation one change at a time, screenshot every screen, and walk step 11 on all of them. Do not iterate the layout with the user: a rule change the pitch needs is the user's one decision, asked once. Use `impeccable` in its Operate mode, with the commands that take away or tidy: `distill` (each screen), `layout`, `typeset`, `quieter`, `clarify`, `polish`. Do not use `bolder`, `overdrive`, `delight` or `animate`; they add. Give it the minimal rule and the flow card as the brief up front: its own rule is "the brief wins". Words on the screens: `tank--ux-writing`. Tap targets are at least 24 by 24 CSS pixels ([WCAG 2.5.8](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html)). 14. **Write the flow card and show the user.** One file, `flow-card.md`, in the agreed words: - the model from Stage 1, as the few claims, at the top; - the real-world scenario (`conceptual-modeling`, Exercise), written before any code was read, and the origin table of step 4; - the job; - the flow before: numbered steps, and the count of steps and screens; - the flow after: the same; - for every step removed, pre-filled, detected, merged or deferred: which one, and why, in one line; - the result of step 11; - every open question, each with your recommendation and why, so the user can answer in one word; - a phone-width screenshot of every screen after the touch-up. Store it in the project's `docs/flows//`. Open that folder for the user with `code -r ` and say the path. You recommend and say why. The supervisor approves the card against the checks above; the user is not a gate. If the user does say something, follow it and do not argue. Record a `functional` item with `data-capture`: what was shown, what they chose, and their reasons in their own words. ## Where it stops At the approved flow card and screens that look right, and for a finished app, at every screen passing every step 11 check, walked by the supervisor on the whole app before each push. Building them is the worker's job. ## Evidence rules A step count comes from walking the flow, never from an estimate. Keep what you saw in the app, what the code does, what a source said, and what you infer separate, and label each. When further reading contradicts a claim you made, change the claim in the model and the card at once and carry only the corrected one. Never put words in the user's mouth: say what you found, what you infer, and let them correct it. Say it small and in plain words. Sources used above, with what each is: NN/g pages are first-party research; WCAG is the standard; the 5-pass reduction is a practitioner's blog. You are given a project (below, as a dump of its files; you cannot read anything else or run commands). Task: organize everything this project's user would want to reach into screens, the map a phone app's navigation will be built from. Do the planning only: build nothing, and don't wait for answers. Where a decision is the user's to make, put it in "questions" with your recommendation. End your answer with the map as ONE fenced ```json block, in exactly this shape: {"screens": [{"path": ["noun", "..."], "sentence": "...", "rows": [{"name": "...", "form": "...", "what": "..."}]}], "questions": ["..."]} - "path": the nouns that lead to the screen, [] for the top screen. - "sentence": what the screen shows, in plain words. - "rows": what can be reached from that screen: "name" in plain words, "form" its technical form (a path, URL, port, command, or the screen it opens), "what" what it is for. # Project: technicians_app_native (a phone app for technicians, Expo Router) ## File tree (src/, supabase/, top level) ``` ./: .gitignore, AGENTS.md, LICENSE, README.md, app.json, eslint.config.js, jsconfig.json, package-lock.json, package.json, vercel.json src/: backOrTo.js, dayText.js, floatingAction.js, jobStateWords.js, latestOnly.js, leadSheet.js, ... (17 files) src/app/: _layout.js, settings.js src/app/(tabs)/: _layout.js, index.js, leads.js src/app/jobs/: new.js src/app/jobs/[id]/: index.js, report.js src/app/leads/: [id].js, import.js, new.js src/components/: ActionDot.js, Autocomplete.js, Button.js, ChevronIcon.js, Choice.js, ClientCard.js, ... (44 files) src/data/: catalogLoader.js, contract.js, dataUrl.js, index.js, localAdapter.js, partPicture.js, partsCatalog.js, seed.js, supabaseAdapter.js, usePartsCatalog.js src/draft/: downscalePhoto.js, draft.js, draftStore.js, useJobDraft.js src/hooks/: useJob.js supabase/: 01_schema.sql, 02_seed.sql, 03_reset_demo.sql, 04_job_state.sql, 05_refill.sql, 06_parts.sql, ... (17 files) supabase/tests/: 00_supabase_standins.sql, assign.test.sql, leads.test.sql, list_leads.test.sql, lost_reason.test.sql, rename.test.sql, rename_before.sql, run_two_kinds_test.sh, tags.test.sql, two_kinds.test.sql ``` ## The owner's decisions (2026-10-21) 2026-10-21, and this is what holds now: "The concept should always be "organization technicians", the only difference is whether the technician has or doesn't have permissions to allocate tasks." Every new job "always go to the "office" (as in a ticket in the dashboard that dashboard operators can assign). admins get to also assign it from the app." The self-worker: "abandon that concept. there won't be any more users for that kind"; Leads: "don't remove it just abandon it. leave it for "self" workers which will never be used". So: - Every technician is an org technician and has the + on My jobs (new job). - A new job is saved with no technician and shows in the dashboard as a ticket waiting to be assigned. The office assigns it there. - An admin technician (a yes/no on their profile, set from the dashboard only) can also pick, on the new-job screen, who does it: anyone in their company, themselves included. The dashboard still sees it, already assigned. - The creator sees the job in My jobs as sent, read only, until it is assigned. If it is assigned to them, it is a normal job. If it is assigned to someone else, the creator no longer sees it (user: "nothing"). - Inside a job, a small + opens a menu: "Create a new job" (the new-job screen, empty) and "Create related job" (the new-job screen with this job's client name, phone and address filled in, and the new job remembers which job it came from, so the office's ticket says where it was found). The user also named "Add as lead"; it is left out because an org technician has no Leads screen to show it on. - `kind = 'self'`, the Leads tab and every self-worker rule stay in the code as they are, unused. No new user gets `kind = 'self'`. Earlier, 2026-10-19 (no longer holds): "There are 2 type of technicians: organization-worker, and self-worker. ..." with the + made self-worker only. The rule for this model (user, 2026-10-19): "some of the changes that we made and applied to org, could very well be valuable for self workers, and we prefer not to diverge the models unless we absolutely have to." So there is one model. Every field is shared, and it differs by kind only where a self-worker cannot use the shared one. - Every technician is an org technician. A few are admins (`can_assign`), set by the office. - Work still comes from the office. New: a technician who finds work in the field sends it to the office as a job with no technician; the office assigns it in the dashboard. - An admin may name the technician when sending. The office still sees the job, already assigned. - The creator sees the job as waiting until it is assigned; then it is theirs, or gone from their list. - Self-worker and Leads stay in the code, unused. ## File: README.md ``` # Technicians App (native, Android) The Betec technicians app as a React Native (Expo) app for Android phones. Hebrew, right-to-left. A technician logs in, sees their jobs, starts a job, adds photos and parts, finishes, has the client sign, and sends the report back. A technician is one of two kinds (`kind` in `tech_profiles`): an **org** worker gets jobs from the office; a **self** worker works for themselves (Leads, and their own jobs). Every technician can open a new job (the + on My jobs, or the + on a job's page for a related job): a self-worker's is their own, an org technician's waits for the office to assign it, or an admin names who does it. The model: `docs/flows/2026-10-19-two-kinds/model.md`. ## Two repos, one app: read this first | | This repo | The web app | |---|---|---| | Name | `technicians_app_native` | [`technicians_app`](https://github.com/Lebnor/technicians_app) (`betec/technicians_app`) | | What it is | React Native (Expo) build for Android | React + Vite web page, deployed on Vercel | | Branch | `main` | `visual_redesign` (its only branch) | | Role | The one we are building now | **The source of truth for how the app looks and behaves** | - The native app is a port of the web app, not a redesign. Same screens, same Hebrew text, same flow, same data. - **What was tested and approved is the web build deployed on Vercel.** When a note, a doc or this repo disagrees with that build, the Vercel build wins. - Ported from web commit `69daa55` (the commit the Vercel Production deployment was built from on 2026-10-14). - The web repo stays as it is. Nothing here changes it. If the web app changes later, the change has to be ported here by hand. ## What was copied and what was rewritten | Part | How it got here | |---|---| | `src/data/contract.js`, `seed.js`, `partsCatalog.js`, `localAdapter.js` | Copied from the web app (the local adapter's storage and id calls swapped for the phone's) | | `src/draft/*` (the unsent work of a job) | Copied; storage changed to the phone's | | Screens and components | Rewritten with React Native, with the same sizes, colours and text as the web app (`src/theme.js` holds the same tokens as the web `index.css`) | | Camera | `expo-image-picker` and `expo-image-manipulator` (photo shrunk to 1280px JPEG, as on web) | | Signature | Drawn with `react-native-svg`; saved as a PNG data URL | | Right-to-left | Forced on at start (`src/app/_layout.js`); the first run on a left-to-right phone restarts once | ## Data The app uses the shared **Betec Supabase project** (`futylfqubotstoxlauly`, the same one as Orders and the dashboard's Torit pages) when `EXPO_PUBLIC_SUPABASE_URL` and `EXPO_PUBLIC_SUPABASE_ANON_KEY` are set at build time (`.env.local`, not committed; copy the two values from `../Orders/.env`). Without them it runs on the local seed data in `src/data/seed.js`, and what the technician does stays on the phone. The tables are in `supabase/` and are run by hand in the project's SQL editor: `01_schema.sql` (every name starts with `tech_`), then `02_seed.sql` (generated from `src/data/seed.js`). A job opened in the app is written by `tech_create_job` (`08_create_job.sql`, the same row as the dashboard's job form). A self-worker's job is their own; since `16_org_technicians_send_jobs.sql` an org technician may open one too: it is saved with no technician (a ticket the office assigns in the dashboard) and `created_by` set to them, unless they are an admin (`tech_profiles.can_assign`, set from the dashboard or the SQL editor only) and name a technician of their own company, themselves included. A job may carry `related_job_id`, the job it was found in. When the client signs and the report is sent, `tech_send_report` saves it, marks the job done and, for an org worker only, takes the amounts the technician set off `tech_parts_inventory`, in one step (a self-worker's report records the parts used and leaves the office's stock alone); the dashboard's Technicians pages read the same tables. `09_two_kinds.sql` adds the kind, a job's planned day and the self-worker's leads table; `10_seed_self_worker.sql` adds the demo self-worker, with jobs that have a planned day and leads in every status. `12_leads_and_planned_day.sql` adds the self-worker's lead calls (`tech_create_lead`, `tech_import_leads`, `tech_set_call_back`, `tech_close_lead`, `tech_open_job_from_lead`) and the planned day on `tech_create_job`; run it after 09, then run 10 again to fill the demo's days and leads. `13_job_without_tags.sql` lets a job be saved with no kind of work and no area (the new-job form no longer asks for them). `14_lost_reason.sql` adds `tech_leads.lost_reason` (only on a lost lead) and a two-argument `tech_close_lead` beside the one-argument one; run both after 12. `15_list_leads_in_pages.sql` adds `tech_list_leads`, the leads list one page at a time in the Leads screen's order (the app needs it: Leads and My jobs load a page at a time; run it after 12, safe to run again, nothing existing changes). `16_org_technicians_send_jobs.sql` (safe to run again; run it after 15, and run the app that calls it only after it) makes every technician able to send the office a job: `tech_profiles.can_assign`, `tech_jobs.technician_id` may be empty, `created_by`, `related_job_id`, one rule for which jobs a technician sees (`tech_job_visible_to_caller`: those assigned to them, and the ones they made that have no technician yet), the nine-argument `tech_create_job` (the seven- and six-argument ones call it), and `tech_list_company_technicians(p_search, p_limit)` for admins (the picker, at most 50 names). The first run makes `user100@gmail.com` the demo's admin. It leaves `tech_set_job_state` and `tech_send_report` as they are: a job with no technician cannot change state or get a report. `03_reset_demo.sql` now also deletes the jobs with no technician (the tickets). To try the long lists locally, start the app without the Supabase settings and with `EXPO_PUBLIC_DEMO_LEADS=5000` and `EXPO_PUBLIC_DEMO_JOBS=5000`: that many demo leads and jobs are added to the local seed (never to the real project). The SQL is tested against a throwaway Postgres in docker with `bash supabase/tests/run_two_kinds_test.sh`; the app's tests run with `npm test`. Test logins (fake data): every test login is `user@gmail.com` with that company code and the same password, the old test password written in `src/data/seed.js`. `user0@gmail.com` / `0` and `user100@gmail.com` / `100` are org workers (`user100` is also an admin: it can name who does a new job); `user200@gmail.com` / `200` is the self-worker (has Leads). The login screen opens filled in with `user100@gmail.com` / `100` (`DEMO_LOGIN` in `src/data/seed.js`). Keep the pattern for any new test login. ## Get the APK on the phone Every push to `main` builds the APK on GitHub (`.github/workflows/android-apk.yml`, about 10 minutes) and publishes it as a release. On the phone, signed in to GitHub, open this repo's **Releases** page, tap the newest `.apk` and install. The last 10 builds are kept. Nothing is built on the laptop. ## Run it ```bash npm install npx expo start --web # the app in a browser, phone-sized (fast way to look at it) npx expo run:android # build and install on a connected Android phone or emulator ``` Expo Go is not enough: it turns right-to-left off. Use the build from `expo run:android`. ## Layout - `src/app/` the screens (Expo Router): `index.js` My jobs, `jobs/new.js` a new job, `jobs/[id]/index.js` the job, `jobs/[id]/report.js` the report. Login is shown by `_layout.js` when nobody is logged in. - `src/components/` the pieces the screens use. `src/data/` the data adapter. `src/draft/` the unsent work of a job. `src/theme.js` the look. ``` ## File: src/app/(tabs)/_layout.js ``` /** Tabs layout - the two places a self-worker moves between, My jobs and Leads, with the bar at the bottom. An org worker has no Leads, so the bar is not shown for them. Pushed screens (a job, a form, Settings) are outside this group and have no bar. */ import { Tabs } from 'expo-router' import { TabBar, TabBarShownContext } from '@/components/TabBar' import { isSelfWorker } from '@/data/contract.js' import { useTechnician } from '@/session' export default function TabsLayout() { const shown = isSelfWorker(useTechnician()) return ( (shown ? : null)}> ) } ``` ## File: src/app/(tabs)/index.js ``` /** My jobs - home after login: my jobs with each job's state, a page at a time (the list may be long), with placeholder rows while a page is on its way; and, for every technician, the + in the title row to open a new job (jobs/new). */ import { router } from 'expo-router' import { Text } from '@/components/Text' import { JobRow } from '@/components/JobRow' import { PageShell } from '@/components/PageShell' import { canCreateJob, data } from '@/data' import { useTechnician } from '@/session' import { usePagedList } from '@/usePagedList' import { text } from '@/theme' export default function JobsScreen() { const technician = useTechnician() // Reloaded on every return to the list, so a job just sent shows as done. const { rows, loaded, loadMore, scroll } = usePagedList({ key: 'jobs', load: (offset, limit) => data.listMyJobs({ offset, limit }), count: () => data.countMyJobs(), }) return ( router.push('/jobs/new') : undefined} addLabel="עבודה חדשה" list={{ rows, renderRow: (job) => router.push(`/jobs/${id}`)} />, empty: loaded && אין עבודות כרגע, onEnd: loadMore, scroll, }} /> ) } ``` ## File: src/app/(tabs)/leads.js ``` /** Leads - a self-worker's second place: the people who might become clients, in the order of the day (call-back day come, new, waiting). Tabs above the list, open and closed with their counts, filter it; the list is long, so it loads a page at a time and holds its place with placeholder rows while a page is on its way. The + adds a lead (leads/new); the one call to action, Excel import, is a black pill floating over the list at the bottom end, with room left for it at the end of the list. The tabs stay at the top while the rows scroll under them (a cell of the list kept by stickyHeaderIndices, given the column's width). A closed lead opens read only. */ import { router, useFocusEffect } from 'expo-router' import { useCallback, useState } from 'react' import { StyleSheet, View } from 'react-native' import { Text } from '@/components/Text' import { Button } from '@/components/Button' import { FilterTabs } from '@/components/FilterTabs' import { Icon } from '@/components/Icon' import { LeadRow } from '@/components/LeadRow' import { PageShell } from '@/components/PageShell' import { SelfWorkerOnly } from '@/components/SelfWorkerOnly' import { data } from '@/data' import { todayISO } from '@/data/contract.js' import { FLOATING_HEIGHT } from '@/floatingAction' import { usePagedList } from '@/usePagedList' import { colors, space, text } from '@/theme' export default function LeadsScreen() { const [showClosed, setShowClosed] = useState(false) // Back on the list always shows the open leads, so a lead just added, called back or closed is where it belongs. useFocusEffect(useCallback(() => { setShowClosed(false) }, [])) const { rows, loaded, counts, loadMore, scroll } = usePagedList({ key: showClosed, load: (offset, limit) => data.listMyLeads({ closed: showClosed, offset, limit }), count: () => data.countMyLeads(), totalOf: (c) => (showClosed ? c.closed : c.open), }) const today = todayISO() const tabs = [ { value: false, label: 'פתוחים', count: counts ? counts.open : null }, { value: true, label: 'סגורים', count: counts ? counts.closed : null }, ] return ( router.push('/leads/new')} addLabel="ליד חדש" list={{ rows, renderRow: (lead) => router.push(`/leads/${id}`)} />, sticky: , empty: loaded && , floating: ( ), onEnd: loadMore, scroll, }} /> ) } function Empty({ closed }) { return closed ? אין לידים סגורים : ( אין לידים עדיין הוסיפו ליד עם +, או ייבאו קובץ אקסל. ) } const styles = StyleSheet.create({ empty: { gap: space.xs }, pill: { borderRadius: FLOATING_HEIGHT / 2, paddingHorizontal: space.lg }, }) ``` ## File: src/app/_layout.js ``` /** Root layout - loads the saved data, then the login gate: no technician, the login screen; otherwise the app's screens. The app is Hebrew, so it is right-to-left on every phone. */ import { reloadAppAsync } from 'expo' import { DefaultTheme, Stack, ThemeProvider, router } from 'expo-router' import { StatusBar } from 'expo-status-bar' import { useEffect, useMemo, useState } from 'react' import { I18nManager, Platform } from 'react-native' import { SafeAreaProvider } from 'react-native-safe-area-context' import { LoginScreen } from '@/components/LoginScreen' import { PageShell } from '@/components/PageShell' import { data } from '@/data' import { navigationTheme } from '@/navigationTheme' import { SessionContext } from '@/session' import { hydrate } from '@/storage' // The layout direction is fixed when the app starts, so the first run on a left-to-right phone restarts once. if (Platform.OS === 'web') { document.documentElement.dir = 'rtl' document.documentElement.lang = 'he' } else if (!I18nManager.isRTL) { I18nManager.allowRTL(true) I18nManager.forceRTL(true) reloadAppAsync() } export default function RootLayout() { const [technician, setTechnician] = useState(undefined) useEffect(() => { hydrate().then(() => data.currentTechnician()).then(setTechnician) }, []) const session = useMemo(() => ({ logout: async () => { await data.logout() setTechnician(null) router.replace('/') }, technician, }), [technician]) return ( {technician === undefined ? : technician ? : } ) } ``` ## File: src/app/jobs/[id]/index.js ``` /** Job - step 2 of main: one job, started, worked and finished with the client. It has no title: the job's state is in the middle of the header, then cards, in the order the technician needs them, the same in every state: the planned day (when the job has one), the description (the office's one free text, with the tags under it), the client (name, phone, address), the parts, and while working the photos, last because they are optional. A done job is read-only, and so is a job still waiting for the office to assign it (no technician yet, so no work to do on it). The + in the header opens a menu: a related job (the client comes along) or a new job. */ import { router, useLocalSearchParams } from 'expo-router' import { useState } from 'react' import { StyleSheet, View } from 'react-native' import { Text } from '@/components/Text' import { ClientCard } from '@/components/ClientCard' import { DoneJobSummary } from '@/components/DoneJobSummary' import { Icon } from '@/components/Icon' import { JobStateTitle } from '@/components/JobState' import { JobTags } from '@/components/JobTags' import { JobWorkspace } from '@/components/JobWorkspace' import { Menu } from '@/components/Menu' import { PageShell } from '@/components/PageShell' import { Row, Section } from '@/components/Section' import { longDay } from '@/dayText' import { shownState } from '@/draft/draft.js' import { useJobDraft } from '@/draft/useJobDraft.js' import { useJob } from '@/hooks/useJob' import { relatedJobParams } from '@/relatedJob.js' import { colors, space, text } from '@/theme' export default function JobScreen() { const { id } = useLocalSearchParams() const { job, missing } = useJob(id) const { draft, change, storageFull } = useJobDraft(id, job) const [menuOpen, setMenuOpen] = useState(false) const goList = () => router.dismissTo('/') if (missing) return if (!job) return const done = job.state === 'done' const openNewJob = (params) => { setMenuOpen(false); router.push({ pathname: '/jobs/new', params }) } return ( } onBack={goList} settings plus={{ label: 'עבודה חדשה או קשורה', onPress: () => setMenuOpen(true) }}> setMenuOpen(false)} items={[ { label: 'עבודה קשורה', onPress: () => openNewJob(relatedJobParams(job)) }, { label: 'עבודה חדשה', onPress: () => openNewJob({}) }, ]} /> {job.plannedDay && (
{longDay(job.plannedDay)}
)} {job.description && (
{job.description}
)} {done ? : job.waitingForOffice ? null : router.push(`/jobs/${job.id}/report`)} onOpenJob={(runningId) => router.push(`/jobs/${runningId}`)} />} ) } const styles = StyleSheet.create({ description: { flex: 1, gap: space.sm }, day: { flex: 1, fontWeight: '600' }, }) ``` ## File: src/app/jobs/[id]/report.js ``` /** Report - step 3 of main: review what the report carries, sign, send. Sending finishes the job and returns to My jobs. */ import { Redirect, router, useLocalSearchParams } from 'expo-router' import { useRef, useState } from 'react' import { StyleSheet } from 'react-native' import { backOrTo } from '@/backOrTo' import { Text, TextInput } from '@/components/Text' import { Button } from '@/components/Button' import { ActionBar, PageShell } from '@/components/PageShell' import { Section } from '@/components/Section' import { SignaturePad } from '@/components/SignaturePad' import { WorkSummary } from '@/components/WorkSummary' import { data, ERRORS } from '@/data' import { elapsedMs, toMinutes } from '@/draft/draft.js' import { clearDraft } from '@/draft/draftStore.js' import { useJobDraft } from '@/draft/useJobDraft.js' import { useJob } from '@/hooks/useJob' import { colors, sizes, space, text } from '@/theme' const SEND_ERRORS = { [ERRORS.jobAlreadyDone]: 'העבודה הזו כבר הושלמה', [ERRORS.jobNotFound]: 'העבודה לא נמצאה', [ERRORS.invalidReport]: 'הדוח חסר פרטים, בדקו את החתימה', } const GENERIC_SEND_ERROR = 'השליחה נכשלה, הטיוטה נשמרה ונסו שוב' export default function ReportScreen() { const { id } = useLocalSearchParams() const { job, missing } = useJob(id) const { draft } = useJobDraft(id, job) const [signature, setSignature] = useState(null) const [notes, setNotes] = useState('') const [error, setError] = useState('') // The moment the report was opened, made once: the time shown is not worked out from the clock on every render. const [openedAt] = useState(() => Date.now()) const sending = useRef(false) const parts = draft.parts async function send() { if (sending.current) return sending.current = true setError('') try { const minutes = toMinutes(elapsedMs(draft, Date.now())) await data.sendReport({ jobId: id, minutes, photos: draft.photos, parts, signature, notes }) clearDraft(id) router.dismissAll() router.replace('/') } catch (e) { setError(SEND_ERRORS[e.code] ?? GENERIC_SEND_ERROR) sending.current = false } } const goList = () => router.dismissTo('/') const goJob = () => backOrTo(router, `/jobs/${id}`) if (missing) return if (!job) return if (job.state === 'done') return return (
{error ? {error} : null}
) } const styles = StyleSheet.create({ input: { minHeight: sizes.row, paddingHorizontal: space.md, paddingVertical: space.sm, fontSize: 16, color: colors.foreground }, }) ``` ## File: src/app/jobs/new.js ``` /** New job - a technician opens a job: what is wanted, the client, and the day it is planned for if known (the kind of work and the area are not asked: the job is saved with no tags until they are read from the description). A self-worker's job is their own; an org technician's waits for the office to assign it, unless an admin (canAssign) names who does it in the last field (data.createJob). It is saved and opens; Back from it goes to My jobs. Opened from a job's + menu as a related job (route param relatedJobId) it is titled "עבודה קשורה" and comes with that job's client; opened from a lead (the lead page's "open a job") it comes filled in from the lead, and saving also turns the lead into a converted one (data.openJobFromLead). */ import { router, useLocalSearchParams } from 'expo-router' import { useRef, useState } from 'react' import { backOrTo } from '@/backOrTo' import { Text } from '@/components/Text' import { Button } from '@/components/Button' import { DayChoice } from '@/components/DayChoice' import { FormField } from '@/components/FormField' import { ActionBar, PageShell } from '@/components/PageShell' import { TechnicianPicker } from '@/components/TechnicianPicker' import { data } from '@/data' import { technicianFieldReady } from '@/technicianOptions.js' import { useTechnician } from '@/session' import { text } from '@/theme' export default function NewJobScreen() { const fromLead = useLocalSearchParams() const [description, setDescription] = useState(fromLead.description ?? '') const [plannedDay, setPlannedDay] = useState(null) const [name, setName] = useState(fromLead.name ?? '') const [phone, setPhone] = useState(fromLead.phone ?? '') const [address, setAddress] = useState(fromLead.address ?? '') const [technician, setTechnician] = useState({ typed: '', id: null }) const canAssign = useTechnician()?.canAssign const [error, setError] = useState('') const saving = useRef(false) const filled = [description, name, phone].every((value) => value.trim()) && technicianFieldReady(technician.typed, technician.id) async function save() { if (saving.current) return saving.current = true setError('') try { const input = { description, plannedDay, workKind: null, areaKind: null, client: { name, phone, address }, relatedJobId: fromLead.relatedJobId, technicianId: technician.id } const job = fromLead.leadId ? await data.openJobFromLead(fromLead.leadId, input) : await data.createJob(input) router.replace(`/jobs/${job.id}`) } catch { setError('השמירה נכשלה, נסו שוב') saving.current = false } } return ( backOrTo(router, fromLead.leadId ? `/leads/${fromLead.leadId}` : '/')} settings> {canAssign && setTechnician({ typed, id })} />} {error ? {error} : null} ) } ``` ## File: src/app/leads/[id].js ``` /** Lead - what a self-worker does after the call (a closed lead, converted or lost, opens read only: the name, where it ended, the reason when one was given, what they want, how to reach them, and nothing to do): the person's name with where the lead stands, what they want, how to reach them, then one tap on a day to call back (it saves and goes back to Leads), or "not relevant" (asks first, with an optional reason, then closes the lead), or "open a job", which opens the new-job screen filled in from the lead. */ import { router, useLocalSearchParams } from 'expo-router' import { useEffect, useState } from 'react' import { StyleSheet, View } from 'react-native' import { backOrTo } from '@/backOrTo' import { Text } from '@/components/Text' import { Button } from '@/components/Button' import { ChevronIcon } from '@/components/ChevronIcon' import { ClientCard } from '@/components/ClientCard' import { CloseLeadDialog } from '@/components/CloseLeadDialog' import { callBackPresets, DayChoice } from '@/components/DayChoice' import { LeadPill } from '@/components/LeadRow' import { ActionBar, PageShell } from '@/components/PageShell' import { Row, Section } from '@/components/Section' import { SelfWorkerOnly } from '@/components/SelfWorkerOnly' import { data } from '@/data' import { isOpenLead, todayISO } from '@/data/contract.js' import { colors, space, text } from '@/theme' export default function LeadScreen() { const { id } = useLocalSearchParams() const [lead, setLead] = useState(undefined) const [closing, setClosing] = useState(false) const [error, setError] = useState('') useEffect(() => { data.getLead(id).then(setLead).catch(() => setLead(null)) }, [id]) const goLeads = () => backOrTo(router, '/leads') async function leave(change) { setError('') try { await change() goLeads() } catch { setError('השמירה נכשלה, נסו שוב') } } return ( {lead === undefined && } {lead === null && } {lead && ( } onBack={goLeads} settings> {lead.lostReason ? סיבה: {lead.lostReason} : null}
{lead.interestedIn} {lead.notes ? {lead.notes} : null}
{isOpenLead(lead) && (
leave(() => data.setCallBack(lead.id, day))} /> setClosing(true)} accessibilityLabel="לא רלוונטי"> לא רלוונטי
)} {isOpenLead(lead) && ( {error ? {error} : null} )} setClosing(false)} onClose={(reason) => { setClosing(false); leave(() => data.closeLead(lead.id, reason)) }} />
)}
) } function openJob(lead) { router.push({ pathname: '/jobs/new', params: { leadId: lead.id, description: lead.interestedIn, name: lead.name, phone: lead.phone, address: lead.address } }) } const styles = StyleSheet.create({ want: { flex: 1, gap: space.xs }, grow: { flex: 1 }, reason: { color: colors.mutedForeground }, callBack: { padding: space.md }, }) ``` ## File: src/app/leads/import.js ``` /** Import leads - a self-worker picks an Excel file of leads; the screen says how many are ready and which rows will not be imported and why (a row needs a name and a phone); "Import N leads" saves them in one call (data.importLeads) and goes back to Leads. */ import * as DocumentPicker from 'expo-document-picker' import { File } from 'expo-file-system' import { router } from 'expo-router' import { useRef, useState } from 'react' import { Platform, StyleSheet, View } from 'react-native' import { backOrTo } from '@/backOrTo' import { Text } from '@/components/Text' import { Button } from '@/components/Button' import { Icon } from '@/components/Icon' import { ActionBar, PageShell } from '@/components/PageShell' import { Row, Section } from '@/components/Section' import { SelfWorkerOnly } from '@/components/SelfWorkerOnly' import { data } from '@/data' import { importRow } from '@/data/contract.js' import { readLeadFile } from '@/leadSheet' import { colors, sizes, space, text } from '@/theme' const XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' const MISSING = { name: 'שם', phone: 'טלפון' } export default function ImportLeadsScreen() { const [file, setFile] = useState(null) const [error, setError] = useState('') const saving = useRef(false) async function pick() { setError('') const result = await DocumentPicker.getDocumentAsync({ type: XLSX_TYPE }) if (result.canceled) return const asset = result.assets[0] try { const entries = await readLeadFile(await readBytes(asset)) const checked = entries.map(({ row, lead }) => ({ row, ...importRow(lead, row - 1) })) setFile({ name: asset.name, ready: checked.filter((c) => c.lead).map((c) => c.lead), skipped: checked.filter((c) => c.skipped).map((c) => c.skipped) }) } catch (e) { setFile(null) setError(e.code === 'no_lead_columns' ? 'בקובץ אין עמודות של שם וטלפון. אלה העמודות שנקראות.' : 'לא ניתן לקרוא את הקובץ. בחרו קובץ אקסל (xlsx).') } } async function save() { if (saving.current) return saving.current = true setError('') try { await data.importLeads(file.ready) backOrTo(router, '/leads') } catch { setError('הייבוא נכשל, נסו שוב') saving.current = false } } return ( backOrTo(router, '/leads')} settings> {!file && ( {'העמודות שנקראות: שם, טלפון, מה רוצה, כתובת, תקציב, מקור, אימייל, הערות. שמות העמודות מהאפליקציה הישנה נקראות גם.'} )} {file && ( <>
{file.name}
{file.skipped.length > 0 && (
{file.skipped.map((row) => ( {row.name ? `שורה ${row.row} · ${row.name}` : `שורה ${row.row}`} {`חסר ${row.missing.map((field) => MISSING[field]).join(' ו')}`} ))}
)} )} {error ? {error} : null} {file ? : }
) } function Count({ value, label }) { return ( {value} {label} ) } // The file's bytes: the browser hands over the File itself, the phone a path in its cache. const readBytes = (asset) => (Platform.OS === 'web' ? asset.file.arrayBuffer() : new File(asset.uri).arrayBuffer()) const styles = StyleSheet.create({ grow: { flex: 1 }, numbers: { flexDirection: 'row', gap: space.sm }, count: { flex: 1, padding: space.md, borderRadius: sizes.radius, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.background }, number: { fontSize: 32, lineHeight: 40, fontWeight: '600', color: colors.foreground }, pickText: { fontWeight: '500', color: colors.background }, }) ``` ## File: src/app/leads/new.js ``` /** New lead - a self-worker writes down someone who might become a client, in the words of the call: name, phone, what they want, and an address if they have one. Budget, source, email and notes are under "More details". Saved as a new lead (data.createLead); Back to Leads, where it is at the top. */ import { router } from 'expo-router' import { useRef, useState } from 'react' import { StyleSheet, View } from 'react-native' import { backOrTo } from '@/backOrTo' import { Text } from '@/components/Text' import { Button } from '@/components/Button' import { ChevronIcon } from '@/components/ChevronIcon' import { FormField } from '@/components/FormField' import { ActionBar, PageShell } from '@/components/PageShell' import { Row, Section } from '@/components/Section' import { SelfWorkerOnly } from '@/components/SelfWorkerOnly' import { data } from '@/data' import { space, text } from '@/theme' export default function NewLeadScreen() { const [lead, setLead] = useState({ name: '', phone: '', interestedIn: '', address: '', budget: '', leadSource: '', email: '', notes: '' }) const [moreOpen, setMoreOpen] = useState(false) const [error, setError] = useState('') const saving = useRef(false) const set = (field) => (value) => setLead({ ...lead, [field]: value }) const filled = [lead.name, lead.phone, lead.interestedIn].every((value) => value.trim()) async function save() { if (saving.current) return saving.current = true setError('') try { await data.createLead(lead) backOrTo(router, '/leads') } catch (e) { setError(e.field === 'lead.budget' ? 'התקציב צריך להיות מספר' : 'השמירה נכשלה, נסו שוב') saving.current = false } } return ( backOrTo(router, '/leads')} settings>
setMoreOpen(!moreOpen)} accessibilityLabel="פרטים נוספים"> פרטים נוספים {moreOpen && ( )}
{error ? {error} : null}
) } const styles = StyleSheet.create({ grow: { flex: 1 }, more: { gap: space.md, padding: space.md }, }) ``` ## File: src/app/settings.js ``` /** Settings - who is logged in, and logging out. Opened from the cog in the header. */ import { router } from 'expo-router' import { useEffect, useState } from 'react' import { backOrTo } from '@/backOrTo' import { Text } from '@/components/Text' import { Button } from '@/components/Button' import { PageShell } from '@/components/PageShell' import { Row, Section } from '@/components/Section' import { data } from '@/data' import { useLogout } from '@/session' import { text } from '@/theme' export default function SettingsScreen() { const [technician, setTechnician] = useState(null) const logout = useLogout() useEffect(() => { data.currentTechnician().then(setTechnician) }, []) return ( backOrTo(router, '/')}> {technician &&
{technician.fullName}
}
) } ``` ## File: src/data/contract.js ``` /** * The data contract screens depend on. Two implementations satisfy it: * localAdapter.js (seed data, in memory / localStorage) and supabaseAdapter.js. * What each thing is, who writes and reads it, and which fields are shared and which differ by kind: docs/flows/2026-10-19-two-kinds/model.md. * * @typedef {'org' | 'self'} TechnicianKind org: works for the office, jobs come from it and its stock is theirs to lower; self: works for themselves, opens their own jobs, no office stock * @typedef {{ id: string, fullName: string, email: string, companyId: string, kind: TechnicianKind, canAssign: boolean }} Technician companyId is the code the technician gives at login; canAssign (an admin) means they may name who does a new job, from their own company; the office sets it, the app cannot * @typedef {'assigned' | 'in_progress' | 'awaiting_signature' | 'done'} JobState in_progress and awaiting_signature are saved by the app (saveWorkState) so the office sees them; done comes with the signed report * @typedef {{ number: number, name: string, pictureUrl?: string }} PartsCatalogItem one accessory the office sells (xl/tmn-devices.xlsx); pictureUrl is the address of its picture, missing when the office gave none * @typedef {{ catalogNumber: number, name: string, quantity: number }} Part an item of the parts catalog, picked from it * @typedef {{ id: string, description: string, tags: string[], state: JobState, parts: Part[], plannedDay: string | null, * client: { name: string, phone: string, address: string }, createdAt: string, createdBy: string | null, relatedJobId: string | null, waitingForOffice: boolean, report?: Report }} Job * parts are the ones the office assigned to the job; [] when none. plannedDay is the day agreed with the client, 'YYYY-MM-DD', null when none. * createdBy is the technician who made the job in the app, null for a job the office made; relatedJobId is the job it was found in, null when none. * waitingForOffice: the job has no technician yet (a ticket the office assigns); only its creator sees it, until it has one. * @typedef {{ id: string, jobId: string, minutes: number, parts: Part[], photos: string[], * signature: string, notes: string, sentAt: string }} Report * @typedef {{ jobId: string, minutes: number, photos: string[], parts: Part[], signature: string, notes?: string }} ReportInput * photos and signature are image data URLs, as the camera and the signature pad produce them. * @typedef {{ description: string, workKind?: string | null, areaKind?: string | null, plannedDay?: string, technicianId?: string, relatedJobId?: string, client: { name: string, phone: string, address?: string } }} JobInput * a new issue the technician opens: the same fields as the office's job form in the dashboard, less the parts (added while working it). plannedDay is 'YYYY-MM-DD', optional. * technicianId (an admin only) is who does it, from the admin's own company; left out, the job waits for the office. relatedJobId is a job the technician can see. * @typedef {'new' | 'contacted' | 'converted' | 'lost'} LeadStatus new: not called yet; contacted: called, with a day to call back; converted: a job was opened from it; lost: the person said no. Open means new or contacted. * @typedef {{ id: string, name: string, phone: string, interestedIn: string, address: string, budget: number | null, leadSource: string, * email: string, notes: string, status: LeadStatus, handlingDate: string | null, createdAt: string, jobId: string | null, lostReason: string | null }} Lead * handlingDate is the day to call back, 'YYYY-MM-DD'; jobId is the job opened from the lead; lostReason is why a lost lead was lost, null when none was given (and always null on a lead that is not lost). * @typedef {{ name: string, phone: string, interestedIn: string, address?: string, budget?: number | string | null, leadSource?: string, email?: string, notes?: string }} LeadInput name, phone and interestedIn are required * @typedef {{ imported: number, skipped: { row: number, name: string, missing: ('name' | 'phone')[] }[] }} ImportResult row counts from 1 in the rows given * * @typedef {Object} DataAdapter * @property {(email: string, password: string, companyId: string) => Promise} login a wrong password or a wrong company code both fail as invalid_credentials * @property {() => Promise} logout * @property {() => Promise} currentTechnician * @property {(options?: { offset?: number, limit?: number }) => Promise} listMyJobs newest first, one page: `limit` jobs from `offset` (both left out: every job) * @property {() => Promise} countMyJobs how many jobs there are, without loading them * @property {(id: string) => Promise} getJob * @property {() => Promise} listPartsCatalog every item, by number * @property {(jobId: string, state: 'in_progress' | 'awaiting_signature') => Promise} saveWorkState tells the office the technician started or stopped * @property {(input: ReportInput) => Promise} sendReport * @property {(input: JobInput) => Promise} createJob opens a new issue, new ('assigned'), with no parts: for a self-worker their own job; for any other technician a job with no technician (waitingForOffice), or with `technicianId` if they are an admin. A missing field fails as invalid_job with its `field`; naming a technician when not allowed (not an admin, another company, nobody that exists) fails as cannot_assign; a relatedJobId they cannot see as job_not_found * @property {(options?: { search?: string, limit?: number }) => Promise<{ id: string, fullName: string }[]>} listCompanyTechnicians an admin's picker: the org technicians of their company whose name contains `search` (any case), themselves first, then by name, at most `limit` (never more than 50); anyone else fails as cannot_assign * The lead calls are for a self-worker's own leads: an org worker fails as not_self_worker, another's lead as lead_not_found, a lead that is converted or lost as lead_closed, a missing field as invalid_lead with its `field`. * @property {(options?: { closed?: boolean, offset?: number, limit?: number }) => Promise} listMyLeads the open leads in the order of orderLeads, or, with closed: true, the converted and lost ones, newest first; one page: `limit` leads from `offset` (both left out: every lead). The database gives the order, so the pages follow each other * @property {() => Promise<{ open: number, closed: number }>} countMyLeads how many leads are open and how many closed, without loading them * @property {(leadId: string) => Promise} getLead one lead of the caller's, whatever its status; lead_not_found when it is not theirs * @property {(input: LeadInput) => Promise} createLead a new lead, status new * @property {(rows: LeadInput[]) => Promise} importLeads saves every row that has a name and a phone, in one call, and counts the others * @property {(leadId: string, day: string) => Promise} setCallBack status contacted, to call back on `day` ('YYYY-MM-DD') * @property {(leadId: string, reason?: string) => Promise} closeLead status lost, with the reason if one is given (trimmed; blank means none) * @property {(leadId: string, input: JobInput) => Promise} openJobFromLead createJob, and the lead becomes converted with that job, together: a refused job leaves the lead as it was */ /** A self-worker works for themselves: they find their own work (leads) and open their own jobs. An org worker's jobs come from the office. */ export const isSelfWorker = (technician) => technician?.kind === 'self' /** Every technician can open a job (the + on My jobs): a self-worker's is their own, an org technician's goes to the office (or to the technician an admin names). */ export const canCreateJob = (technician) => technician != null export const LEAD_STATUSES = ['new', 'contacted', 'converted', 'lost'] export const JOB_STATES = ['assigned', 'in_progress', 'awaiting_signature', 'done'] /** A job's two tags, in this order: the kind of work, then the area. The same lists as WORK_KINDS and AREA_KINDS in the dashboard's api/technicians.ts and in supabase/08_create_job.sql; the repos share no code, so a kind added in one place is added in all three. */ export const WORK_KINDS = ['התקנה', 'תיקון', 'בדיקה'] export const AREA_KINDS = ['אלקטרוניקה', 'מכני'] export const ERRORS = { invalidCredentials: 'invalid_credentials', notLoggedIn: 'not_logged_in', jobNotFound: 'job_not_found', jobAlreadyDone: 'job_already_done', invalidReport: 'invalid_report', invalidJob: 'invalid_job', invalidLead: 'invalid_lead', notSelfWorker: 'not_self_worker', cannotAssign: 'cannot_assign', leadNotFound: 'lead_not_found', leadClosed: 'lead_closed', } export function dataError(code) { return Object.assign(new Error(code), { code }) } export function assertValidReport({ minutes, signa [... cut at 9000 characters] ```