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] ```