@oneteam-app/apps-cli 0.1.0-alpha.5 → 0.1.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +46 -2
- package/dist/index.js +1943 -54
- package/package.json +8 -6
- package/templates/hosted/.dev.vars.example +11 -0
- package/templates/hosted/README.md +119 -0
- package/templates/hosted/_gitignore +11 -0
- package/templates/hosted/index.html +16 -0
- package/templates/hosted/migrations/0001_init.sql +16 -0
- package/templates/hosted/oneteam.app.json +17 -0
- package/templates/hosted/package.json +34 -0
- package/templates/hosted/src/client/App.tsx +276 -0
- package/templates/hosted/src/client/main.tsx +10 -0
- package/templates/hosted/src/client/styles.css +6 -0
- package/templates/hosted/src/shared.ts +22 -0
- package/templates/hosted/src/worker/dev.ts +15 -0
- package/templates/hosted/src/worker/env.d.ts +10 -0
- package/templates/hosted/src/worker/index.test.ts +113 -0
- package/templates/hosted/src/worker/index.ts +121 -0
- package/templates/hosted/src/worker/items.ts +85 -0
- package/templates/hosted/src/worker/jobs.ts +22 -0
- package/templates/hosted/tsconfig.json +16 -0
- package/templates/hosted/tsconfig.worker.json +16 -0
- package/templates/hosted/vite.config.ts +15 -0
- package/templates/hosted/vitest.config.ts +16 -0
- package/templates/hosted/wrangler.jsonc +18 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oneteam-app/apps-cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
4
|
-
"description": "Set up
|
|
3
|
+
"version": "0.1.0-alpha.7",
|
|
4
|
+
"description": "Set up, check and deploy apps for Oneteam: init, validate, doctor, login, deploy, status, logs.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"oneteam",
|
|
7
7
|
"cli",
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"CHANGELOG.md",
|
|
28
28
|
"LICENSE",
|
|
29
29
|
"README.md",
|
|
30
|
-
"dist"
|
|
30
|
+
"dist",
|
|
31
|
+
"templates"
|
|
31
32
|
],
|
|
32
33
|
"engines": {
|
|
33
34
|
"node": ">=20.9"
|
|
@@ -37,11 +38,12 @@
|
|
|
37
38
|
"provenance": false
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|
|
40
|
-
"@oneteam-app/apps": "0.1.0-alpha.
|
|
41
|
+
"@oneteam-app/apps": "0.1.0-alpha.7",
|
|
42
|
+
"esbuild": "^0.28.1"
|
|
41
43
|
},
|
|
42
44
|
"scripts": {
|
|
43
|
-
"build": "tsup",
|
|
44
|
-
"test": "vitest run",
|
|
45
|
+
"build": "node scripts/copy-template.mjs && tsup",
|
|
46
|
+
"test": "node scripts/copy-template.mjs && vitest run",
|
|
45
47
|
"typecheck": "tsc --noEmit"
|
|
46
48
|
}
|
|
47
49
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Local values for the secrets listed under "secrets" in oneteam.app.json.
|
|
2
|
+
#
|
|
3
|
+
# Copy this file to .dev.vars and fill it in yourself. .dev.vars is git-ignored
|
|
4
|
+
# and never uploaded: the deployed app gets its values from a person who enters
|
|
5
|
+
# them in the dashboard (the app's Secrets tab) or with
|
|
6
|
+
# npx oneteam-apps secret set NAME --env preview
|
|
7
|
+
# npx oneteam-apps secret set NAME --env live
|
|
8
|
+
#
|
|
9
|
+
# Use a test key here, never the live one. Names only in this file:
|
|
10
|
+
#
|
|
11
|
+
# SUPABASE_SERVICE_KEY=
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Lost and found (hosted app template)
|
|
2
|
+
|
|
3
|
+
A small, complete hosted app: a React front end, an API under `/api`, and its
|
|
4
|
+
own database. People are signed in before a request reaches the app, so there
|
|
5
|
+
is no login code here. `oneteam-apps init --hosted <name>` copies this folder.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm install
|
|
9
|
+
pnpm dev # http://localhost:5310, with a "View as" switch instead of sign-on
|
|
10
|
+
pnpm test # the permission tests
|
|
11
|
+
pnpm run deploy # review, then a preview address
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
| | |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `src/worker/index.ts` | The API (Hono). Every route has `c.get('session')`; writes check a permission with `assertCan` |
|
|
17
|
+
| `src/worker/items.ts` | All database access: `env.DB` only, placeholders only, always limited to the user's communities |
|
|
18
|
+
| `src/worker/index.test.ts` | Who can see and do what, with fake users |
|
|
19
|
+
| `src/worker/jobs.ts` | Jobs the platform runs on a schedule. Empty to start with |
|
|
20
|
+
| `src/worker/dev.ts` | Local stand-in for sign-on. Not part of what is deployed |
|
|
21
|
+
| `src/client/` | The front end, built from the components of `@oneteam-app/ui`. Calls the API through `apiJson()`, never `fetch('/api/…')` |
|
|
22
|
+
| `src/client/styles.css` | Two imports: Tailwind and the theme. No colours of your own |
|
|
23
|
+
| `migrations/` | The database schema, applied in order |
|
|
24
|
+
| `oneteam.app.json` | Name, address, what personal data is stored, who should see it, which secrets it needs |
|
|
25
|
+
| `.dev.vars.example` | The names of the secrets, for whoever runs the app locally. `.dev.vars` itself is never committed |
|
|
26
|
+
|
|
27
|
+
The app does not live at `/`: it is served at `/<organisation>/<app>/`, and the
|
|
28
|
+
same build at `/<organisation>/<app>--preview/`. That is why Vite builds with
|
|
29
|
+
`base: './'`, why `index.html` has a `<base>` tag, and why the front end uses
|
|
30
|
+
`apiJson()` and `basePath()` from `@oneteam-app/apps/hosted/react`.
|
|
31
|
+
|
|
32
|
+
## How it looks
|
|
33
|
+
|
|
34
|
+
Screens are built from `@oneteam-app/ui` (`Page`, `List`, `Button`, `Field`, …;
|
|
35
|
+
the list is in `node_modules/@oneteam-app/ui/docs/components.md`). The app is
|
|
36
|
+
wrapped once in `<OneteamTheme theme={themeOf(me)}>`, which applies the
|
|
37
|
+
organisation's colour and logo and follows the device's light or dark mode.
|
|
38
|
+
Tailwind is the one way to style: layout classes in the markup, written for a
|
|
39
|
+
phone first (`grid gap-6 md:grid-cols-2` adds the second column on a wide
|
|
40
|
+
screen), and the theme's names (`bg-card`, `text-muted-foreground`) where a
|
|
41
|
+
class needs a colour. There are no colours and no other `.css` files in this app.
|
|
42
|
+
|
|
43
|
+
`pnpm dev` has a "Button colour" box in the bar at the top: type a hex colour to see the
|
|
44
|
+
app as an organisation with that colour would. `oneteam-apps doctor` warns about
|
|
45
|
+
colours written out and hand-made buttons, inputs and dialogs.
|
|
46
|
+
|
|
47
|
+
## Secrets
|
|
48
|
+
|
|
49
|
+
The app has its own database and needs no key for it. When it calls another
|
|
50
|
+
service (Supabase, a mail provider), the key for that service is a secret: the
|
|
51
|
+
code and `oneteam.app.json` only name it, and a person enters the value.
|
|
52
|
+
|
|
53
|
+
1. List the name, and the host it is sent to, in `oneteam.app.json`:
|
|
54
|
+
```json
|
|
55
|
+
"outbound_hosts": ["xyz.supabase.co"],
|
|
56
|
+
"secrets": [{ "name": "SUPABASE_SERVICE_KEY", "host": "xyz.supabase.co" }]
|
|
57
|
+
```
|
|
58
|
+
2. Add it to `Env` in `src/worker/index.ts` and read it in the Worker only:
|
|
59
|
+
```ts
|
|
60
|
+
import { secret } from '@oneteam-app/apps/hosted';
|
|
61
|
+
|
|
62
|
+
const key = secret(c.env, 'SUPABASE_SERVICE_KEY');
|
|
63
|
+
```
|
|
64
|
+
It throws a clear error when the value is missing. Never return the value
|
|
65
|
+
from a route, log it, or give it a `VITE_` name: everything in `src/client/`
|
|
66
|
+
is downloaded by the browser.
|
|
67
|
+
3. Locally: copy `.dev.vars.example` to `.dev.vars` and fill in a test key yourself.
|
|
68
|
+
4. Deployed: a person enters the value once per environment, in the dashboard
|
|
69
|
+
(the app's Secrets tab) or at a hidden prompt:
|
|
70
|
+
```bash
|
|
71
|
+
npx oneteam-apps secret set SUPABASE_SERVICE_KEY --env preview
|
|
72
|
+
npx oneteam-apps secret set SUPABASE_SERVICE_KEY --env live
|
|
73
|
+
npx oneteam-apps secret list
|
|
74
|
+
```
|
|
75
|
+
Preview and live each have their own value. After saving, nobody can read it
|
|
76
|
+
back, and a version cannot go live while one of its secrets has no live value.
|
|
77
|
+
|
|
78
|
+
`oneteam-apps deploy` and `oneteam-apps doctor` stop when an env file is not
|
|
79
|
+
git-ignored or a key is written into the code.
|
|
80
|
+
|
|
81
|
+
## Scheduled jobs and sending
|
|
82
|
+
|
|
83
|
+
A job is a function the platform calls on a schedule. Sending is a permission
|
|
84
|
+
an admin approves. Neither needs a key, a cron service or anything outside the app.
|
|
85
|
+
|
|
86
|
+
1. Say when it runs, and what the app sends, in `oneteam.app.json`:
|
|
87
|
+
```json
|
|
88
|
+
"schedules": [{ "name": "send-reminders", "every": "day", "at": "07:00" }],
|
|
89
|
+
"sends": ["notifications"]
|
|
90
|
+
```
|
|
91
|
+
`every` is `15_minutes`, `30_minutes`, `hour` (with `minute`), `day`, `week`
|
|
92
|
+
(with `weekdays`) or `month` (with `day_of_month`, 1 to 28). Not cron syntax.
|
|
93
|
+
2. Write the handler under the same name in `src/worker/jobs.ts`:
|
|
94
|
+
```ts
|
|
95
|
+
export const jobs = defineJobs<Env>({
|
|
96
|
+
'send-reminders': async (job, env) => {
|
|
97
|
+
const waiting = await itemsWaitingLongerThan(env.DB, 7);
|
|
98
|
+
|
|
99
|
+
await notify(job, { userIds: waiting.map((item) => item.foundBy), title: 'Still waiting', body: 'An item you logged is still here.' });
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
Nobody is signed in during a job: there is no session, so take who it is
|
|
104
|
+
for from the app's own data. From a route, send for the person's request:
|
|
105
|
+
`await notify(c.req.raw, { … })`.
|
|
106
|
+
3. Try it: `pnpm dev`, then "Run job" in the bar at the top. What the app sends
|
|
107
|
+
is printed in the terminal.
|
|
108
|
+
4. `oneteam-apps deploy` checks that every schedule has a handler and the other
|
|
109
|
+
way round, and says what an admin has to approve: a new job, one that runs
|
|
110
|
+
more often, anything new under `sends`. Then:
|
|
111
|
+
```bash
|
|
112
|
+
npx oneteam-apps job run send-reminders --env preview
|
|
113
|
+
npx oneteam-apps job list
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A job has 30 seconds. A run that was missed is skipped, not caught up, and a
|
|
117
|
+
run can be started by hand, so a job has to be safe to run twice. People
|
|
118
|
+
outside the app's audience do not get notifications (they come back under
|
|
119
|
+
`skipped`), and nobody gets more than five a day from one app.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<!-- Where the app is mounted. Locally that is the root; in production the
|
|
5
|
+
gateway replaces this with /<organisation>/<app>/. -->
|
|
6
|
+
<base href="/" />
|
|
7
|
+
<meta charset="utf-8" />
|
|
8
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
9
|
+
<meta name="theme-color" content="#ffffff" />
|
|
10
|
+
<title>Lost and found</title>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div id="root"></div>
|
|
14
|
+
<script type="module" src="/src/client/main.tsx"></script>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- One row per found item. Every query filters on community_id: people only
|
|
2
|
+
-- see and change items of the communities (stores) they belong to.
|
|
3
|
+
CREATE TABLE items (
|
|
4
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
5
|
+
community_id TEXT NOT NULL,
|
|
6
|
+
title TEXT NOT NULL,
|
|
7
|
+
location TEXT NOT NULL DEFAULT '',
|
|
8
|
+
found_by_user_id TEXT NOT NULL,
|
|
9
|
+
found_by_name TEXT NOT NULL,
|
|
10
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
11
|
+
returned_at TEXT,
|
|
12
|
+
returned_to TEXT,
|
|
13
|
+
returned_by_user_id TEXT
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE INDEX items_by_community ON items (community_id, created_at DESC);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://oneteam.io/schemas/app.v2.json",
|
|
3
|
+
"schema_version": 2,
|
|
4
|
+
"hosting": "hosted",
|
|
5
|
+
"name": "Lost and found",
|
|
6
|
+
"description": "Log items found in the store and mark them as returned",
|
|
7
|
+
"slug": "lost-and-found",
|
|
8
|
+
"summary": "First version: log found items, list them per store, mark them as returned.",
|
|
9
|
+
"personal_data": [
|
|
10
|
+
"Name of the employee who logged an item",
|
|
11
|
+
"Name of the person an item was returned to"
|
|
12
|
+
],
|
|
13
|
+
"outbound_hosts": [],
|
|
14
|
+
"suggested_audience": {
|
|
15
|
+
"description": "Everyone who works in a store"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hosted-app",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "wrangler d1 migrations apply DB --local && vite dev",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"test": "vitest run",
|
|
9
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.worker.json --noEmit",
|
|
10
|
+
"deploy": "oneteam-apps deploy"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@oneteam-app/apps": "workspace:*",
|
|
14
|
+
"@oneteam-app/ui": "workspace:*",
|
|
15
|
+
"hono": "^4.13.0",
|
|
16
|
+
"react": "^19.1.0",
|
|
17
|
+
"react-dom": "^19.1.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@cloudflare/vite-plugin": "^1.64.0",
|
|
21
|
+
"@cloudflare/vitest-plugin": "^1.7.0",
|
|
22
|
+
"@cloudflare/workers-types": "^5.20261110.1",
|
|
23
|
+
"@oneteam-app/apps-cli": "workspace:*",
|
|
24
|
+
"@tailwindcss/vite": "^4.3.5",
|
|
25
|
+
"@types/react": "^19.1.0",
|
|
26
|
+
"@types/react-dom": "^19.1.0",
|
|
27
|
+
"@vitejs/plugin-react": "^6.1.0",
|
|
28
|
+
"tailwindcss": "^4.3.5",
|
|
29
|
+
"typescript": "^5.9.0",
|
|
30
|
+
"vite": "^8.3.0",
|
|
31
|
+
"vitest": "^4.1.11",
|
|
32
|
+
"wrangler": "^4.160.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { apiJson, themeOf, useMe } from '@oneteam-app/apps/hosted/react';
|
|
2
|
+
import {
|
|
3
|
+
ActionBar,
|
|
4
|
+
Badge,
|
|
5
|
+
Button,
|
|
6
|
+
Card,
|
|
7
|
+
EmptyState,
|
|
8
|
+
ErrorState,
|
|
9
|
+
Field,
|
|
10
|
+
List,
|
|
11
|
+
ListItem,
|
|
12
|
+
ListSkeleton,
|
|
13
|
+
OneteamTheme,
|
|
14
|
+
Page,
|
|
15
|
+
Person,
|
|
16
|
+
Section,
|
|
17
|
+
Select,
|
|
18
|
+
Tabs,
|
|
19
|
+
TextInput,
|
|
20
|
+
toast,
|
|
21
|
+
} from '@oneteam-app/ui';
|
|
22
|
+
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
|
23
|
+
import type { Item, ItemsResponse } from '../shared';
|
|
24
|
+
|
|
25
|
+
type Screen = { name: 'list' } | { name: 'add' } | { name: 'item'; item: Item } | { name: 'return'; item: Item };
|
|
26
|
+
|
|
27
|
+
function formatDate(value: string): string {
|
|
28
|
+
return new Date(value).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function App() {
|
|
32
|
+
const { me, status } = useMe();
|
|
33
|
+
const [data, setData] = useState<ItemsResponse | null>(null);
|
|
34
|
+
const [screen, setScreen] = useState<Screen>({ name: 'list' });
|
|
35
|
+
const [failed, setFailed] = useState(false);
|
|
36
|
+
|
|
37
|
+
const load = useCallback(async () => {
|
|
38
|
+
try {
|
|
39
|
+
// Always through the helper: it knows the address the app is mounted at.
|
|
40
|
+
setData(await apiJson<ItemsResponse>('/api/items'));
|
|
41
|
+
setFailed(false);
|
|
42
|
+
} catch {
|
|
43
|
+
setFailed(true);
|
|
44
|
+
}
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
useEffect(() => void load(), [load]);
|
|
48
|
+
|
|
49
|
+
const toList = () => setScreen({ name: 'list' });
|
|
50
|
+
const saved = (message: string) => {
|
|
51
|
+
toast.success(message);
|
|
52
|
+
toList();
|
|
53
|
+
void load();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
// The organisation's theme (its colours and logo), when it has set any; neutral otherwise.
|
|
58
|
+
<OneteamTheme theme={themeOf(me)}>
|
|
59
|
+
{status === 'error' || failed ? (
|
|
60
|
+
<Page title="Lost and found">
|
|
61
|
+
<ErrorState description="The items could not be loaded." onRetry={() => (status === 'error' ? location.reload() : void load())} />
|
|
62
|
+
</Page>
|
|
63
|
+
) : screen.name === 'add' && data ? (
|
|
64
|
+
<AddItem communities={data.communities} onSaved={() => saved('Item logged')} onBack={toList} />
|
|
65
|
+
) : screen.name === 'item' ? (
|
|
66
|
+
<ItemDetail
|
|
67
|
+
item={screen.item}
|
|
68
|
+
storeName={data?.communities.find((community) => community.id === screen.item.communityId)?.name ?? ''}
|
|
69
|
+
onReturn={() => setScreen({ name: 'return', item: screen.item })}
|
|
70
|
+
onBack={toList}
|
|
71
|
+
/>
|
|
72
|
+
) : screen.name === 'return' ? (
|
|
73
|
+
<ReturnItem item={screen.item} onSaved={() => saved('Marked as returned')} onBack={() => setScreen({ name: 'item', item: screen.item })} />
|
|
74
|
+
) : (
|
|
75
|
+
<ItemList data={data} onAdd={() => setScreen({ name: 'add' })} onOpen={(item) => setScreen({ name: 'item', item })} />
|
|
76
|
+
)}
|
|
77
|
+
</OneteamTheme>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ItemList({ data, onAdd, onOpen }: { data: ItemsResponse | null; onAdd: () => void; onOpen: (item: Item) => void }) {
|
|
82
|
+
const open = data?.items.filter((item) => !item.returnedAt) ?? [];
|
|
83
|
+
const returned = data?.items.filter((item) => item.returnedAt) ?? [];
|
|
84
|
+
const storeName = (id: string) => data?.communities.find((community) => community.id === id)?.name ?? '';
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<Page
|
|
88
|
+
title="Lost and found"
|
|
89
|
+
showBrand
|
|
90
|
+
footer={
|
|
91
|
+
// One main action per screen, where the thumb reaches it.
|
|
92
|
+
<ActionBar>
|
|
93
|
+
<Button onClick={onAdd} disabled={!data}>
|
|
94
|
+
Log a found item
|
|
95
|
+
</Button>
|
|
96
|
+
</ActionBar>
|
|
97
|
+
}
|
|
98
|
+
>
|
|
99
|
+
{!data ? (
|
|
100
|
+
<ListSkeleton />
|
|
101
|
+
) : (
|
|
102
|
+
<Tabs
|
|
103
|
+
tabs={[
|
|
104
|
+
{
|
|
105
|
+
value: 'open',
|
|
106
|
+
label: `Waiting (${open.length})`,
|
|
107
|
+
content:
|
|
108
|
+
open.length === 0 ? (
|
|
109
|
+
<EmptyState title="Nothing waiting to be picked up" description="Items you log show up here." />
|
|
110
|
+
) : (
|
|
111
|
+
<List>
|
|
112
|
+
{open.map((item) => (
|
|
113
|
+
<ListItem
|
|
114
|
+
key={item.id}
|
|
115
|
+
title={item.title}
|
|
116
|
+
description={[item.location, storeName(item.communityId), formatDate(item.createdAt)].filter(Boolean).join(' · ')}
|
|
117
|
+
onClick={() => onOpen(item)}
|
|
118
|
+
/>
|
|
119
|
+
))}
|
|
120
|
+
</List>
|
|
121
|
+
),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
value: 'returned',
|
|
125
|
+
label: `Returned (${returned.length})`,
|
|
126
|
+
content:
|
|
127
|
+
returned.length === 0 ? (
|
|
128
|
+
<EmptyState title="Nothing returned yet" />
|
|
129
|
+
) : (
|
|
130
|
+
<List>
|
|
131
|
+
{returned.map((item) => (
|
|
132
|
+
<ListItem
|
|
133
|
+
key={item.id}
|
|
134
|
+
title={item.title}
|
|
135
|
+
description={`To ${item.returnedTo} · ${formatDate(item.returnedAt!)}`}
|
|
136
|
+
trailing={<Badge tone="success">Returned</Badge>}
|
|
137
|
+
onClick={() => onOpen(item)}
|
|
138
|
+
/>
|
|
139
|
+
))}
|
|
140
|
+
</List>
|
|
141
|
+
),
|
|
142
|
+
},
|
|
143
|
+
]}
|
|
144
|
+
/>
|
|
145
|
+
)}
|
|
146
|
+
</Page>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One item. A single column on a phone; what was found and what happened to it side by side on a wide screen. */
|
|
151
|
+
function ItemDetail({ item, storeName, onReturn, onBack }: { item: Item; storeName: string; onReturn: () => void; onBack: () => void }) {
|
|
152
|
+
return (
|
|
153
|
+
<Page
|
|
154
|
+
title={item.title}
|
|
155
|
+
description={storeName}
|
|
156
|
+
back={onBack}
|
|
157
|
+
width="wide"
|
|
158
|
+
// Only for people who may hand items back; the API checks again.
|
|
159
|
+
footer={
|
|
160
|
+
item.canReturn && !item.returnedAt ? (
|
|
161
|
+
<ActionBar>
|
|
162
|
+
<Button onClick={onReturn}>Hand back</Button>
|
|
163
|
+
</ActionBar>
|
|
164
|
+
) : undefined
|
|
165
|
+
}
|
|
166
|
+
>
|
|
167
|
+
<div className="grid gap-6 md:grid-cols-2">
|
|
168
|
+
<Section title="Found">
|
|
169
|
+
<Card>
|
|
170
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2">
|
|
171
|
+
<dt className="text-muted-foreground">Where</dt>
|
|
172
|
+
<dd>{item.location || 'Not noted'}</dd>
|
|
173
|
+
<dt className="text-muted-foreground">Store</dt>
|
|
174
|
+
<dd>{storeName}</dd>
|
|
175
|
+
<dt className="text-muted-foreground">When</dt>
|
|
176
|
+
<dd>{formatDate(item.createdAt)}</dd>
|
|
177
|
+
</dl>
|
|
178
|
+
</Card>
|
|
179
|
+
<Person name={item.foundByName} detail="Logged this item" />
|
|
180
|
+
</Section>
|
|
181
|
+
<Section title="Status">
|
|
182
|
+
<Card
|
|
183
|
+
title={item.returnedAt ? <Badge tone="success">Returned</Badge> : <Badge tone="warning">Waiting to be picked up</Badge>}
|
|
184
|
+
description={item.returnedAt ? `To ${item.returnedTo} on ${formatDate(item.returnedAt)}` : 'Kept in the store until someone asks for it.'}
|
|
185
|
+
/>
|
|
186
|
+
</Section>
|
|
187
|
+
</div>
|
|
188
|
+
</Page>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Sends a form once, and turns what can go wrong into words for the person who filled it in. */
|
|
193
|
+
function useSubmit(send: () => Promise<unknown>, onSaved: () => void) {
|
|
194
|
+
const [busy, setBusy] = useState(false);
|
|
195
|
+
const [error, setError] = useState<string | null>(null);
|
|
196
|
+
|
|
197
|
+
const submit = async (event: FormEvent) => {
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
setBusy(true);
|
|
200
|
+
setError(null);
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
await send();
|
|
204
|
+
onSaved();
|
|
205
|
+
} catch (caught) {
|
|
206
|
+
setError((caught as { status?: number }).status === 403 ? "You're not allowed to do this." : (caught as Error).message);
|
|
207
|
+
setBusy(false);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
return { busy, error, submit };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function AddItem({ communities, onSaved, onBack }: { communities: ItemsResponse['communities']; onSaved: () => void; onBack: () => void }) {
|
|
215
|
+
const [title, setTitle] = useState('');
|
|
216
|
+
const [location, setLocation] = useState('');
|
|
217
|
+
const [communityId, setCommunityId] = useState(communities[0]?.id ?? '');
|
|
218
|
+
const { busy, error, submit } = useSubmit(
|
|
219
|
+
() => apiJson('/api/items', { method: 'POST', body: { title, location, communityId: communityId || undefined } }),
|
|
220
|
+
onSaved,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<form onSubmit={submit}>
|
|
225
|
+
<Page
|
|
226
|
+
title="Log a found item"
|
|
227
|
+
back={onBack}
|
|
228
|
+
footer={
|
|
229
|
+
<ActionBar>
|
|
230
|
+
<Button type="submit" loading={busy}>
|
|
231
|
+
Save
|
|
232
|
+
</Button>
|
|
233
|
+
</ActionBar>
|
|
234
|
+
}
|
|
235
|
+
>
|
|
236
|
+
<Field label="What was found?" error={error} required>
|
|
237
|
+
<TextInput value={title} onChange={(event) => setTitle(event.target.value)} maxLength={120} autoFocus placeholder="Blue umbrella" />
|
|
238
|
+
</Field>
|
|
239
|
+
<Field label="Where?">
|
|
240
|
+
<TextInput value={location} onChange={(event) => setLocation(event.target.value)} maxLength={120} placeholder="Fitting rooms" />
|
|
241
|
+
</Field>
|
|
242
|
+
{communities.length > 1 && (
|
|
243
|
+
<Field label="Store">
|
|
244
|
+
<Select value={communityId} onChange={(event) => setCommunityId(event.target.value)} options={communities.map(({ id, name }) => ({ value: id, label: name }))} />
|
|
245
|
+
</Field>
|
|
246
|
+
)}
|
|
247
|
+
</Page>
|
|
248
|
+
</form>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function ReturnItem({ item, onSaved, onBack }: { item: Item; onSaved: () => void; onBack: () => void }) {
|
|
253
|
+
const [returnedTo, setReturnedTo] = useState('');
|
|
254
|
+
const { busy, error, submit } = useSubmit(() => apiJson(`/api/items/${item.id}/return`, { method: 'POST', body: { returnedTo } }), onSaved);
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<form onSubmit={submit}>
|
|
258
|
+
<Page
|
|
259
|
+
title="Hand back an item"
|
|
260
|
+
description={item.title}
|
|
261
|
+
back={onBack}
|
|
262
|
+
footer={
|
|
263
|
+
<ActionBar>
|
|
264
|
+
<Button type="submit" loading={busy}>
|
|
265
|
+
Mark as returned
|
|
266
|
+
</Button>
|
|
267
|
+
</ActionBar>
|
|
268
|
+
}
|
|
269
|
+
>
|
|
270
|
+
<Field label="Who picked it up?" error={error} required>
|
|
271
|
+
<TextInput value={returnedTo} onChange={(event) => setReturnedTo(event.target.value)} maxLength={120} autoFocus />
|
|
272
|
+
</Field>
|
|
273
|
+
</Page>
|
|
274
|
+
</form>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/* The look comes from @oneteam-app/ui: its components and its theme. The
|
|
2
|
+
organisation's own colour is applied by <OneteamTheme>. Add your own rules
|
|
3
|
+
below only for layout the components do not cover, and use the theme's
|
|
4
|
+
tokens (bg-card, text-muted-foreground, var(--primary)…), never a colour. */
|
|
5
|
+
@import "tailwindcss";
|
|
6
|
+
@import "@oneteam-app/ui/theme.css";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Shapes the API returns and the front end reads.
|
|
2
|
+
|
|
3
|
+
export interface Item {
|
|
4
|
+
id: number;
|
|
5
|
+
communityId: string;
|
|
6
|
+
title: string;
|
|
7
|
+
location: string;
|
|
8
|
+
foundByName: string;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
returnedAt: string | null;
|
|
11
|
+
returnedTo: string | null;
|
|
12
|
+
/** Whether the signed-in user may mark this item as returned. The API checks again */
|
|
13
|
+
canReturn: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ItemsResponse {
|
|
17
|
+
items: Item[];
|
|
18
|
+
communities: Array<{ id: string; name: string }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Who may mark an item as returned: people who manage colleagues in the item's community. */
|
|
22
|
+
export const RETURN_PERMISSION = 'network::users.update';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { withDevSignOn } from '@oneteam-app/apps/testing';
|
|
2
|
+
import manifest from '../../oneteam.app.json';
|
|
3
|
+
import worker, { type Env } from './index';
|
|
4
|
+
import { jobs } from './jobs';
|
|
5
|
+
|
|
6
|
+
// Local development only (wrangler.jsonc points here). Stands in for the
|
|
7
|
+
// sign-on gateway: every request is made as the person picked in the "View as"
|
|
8
|
+
// bar at the top of the page, so the app runs without a network. The bar can
|
|
9
|
+
// also run a job by hand, and what the app sends is printed in this terminal
|
|
10
|
+
// instead of delivered (only what oneteam.app.json lists under "sends").
|
|
11
|
+
// `oneteam-apps deploy` bundles src/worker/index.ts, which has none of this.
|
|
12
|
+
export default withDevSignOn<Env>(worker, {
|
|
13
|
+
jobs,
|
|
14
|
+
sends: (manifest as { sends?: Array<'notifications' | 'chat_messages'> }).sends ?? [],
|
|
15
|
+
});
|