feat: add communication support and journal prototypes
This commit is contained in:
@@ -1,4 +1,12 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-09-28",
|
||||
"macrostructure": "communication workbench and append-only journal",
|
||||
"theme": "shadcn base-nova neutral",
|
||||
"scope": "support",
|
||||
"enrichment": "none",
|
||||
"brief": "Compact execution support integrated with common sidebar, editable reply review and journal corrections"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-28",
|
||||
"macrostructure": "sidebar-09 Inbox",
|
||||
|
||||
@@ -30,6 +30,11 @@ Articles are fictional samples; search and article selection work locally.
|
||||
On mobile, the article list and reader switch views. The shared sidebar’s bottom
|
||||
avatar opens connected-account management from either workspace.
|
||||
|
||||
The shared sidebar also contains `/support` (communication support) and `/journal`
|
||||
(notes and health records). These use fictional conversations and local session
|
||||
state to preview reply review, scheduling, deferral, recording, and corrections.
|
||||
See [execution support](docs/execution-support.md) for scope and limitations.
|
||||
|
||||
The former SNS reader, search,
|
||||
list, user-profile, and conversation routes have been removed. Original-post
|
||||
links on post cards open their source site; SNS reply threads are not rendered
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Execution support prototype
|
||||
|
||||
The shared sidebar opens `/support` (連絡) and `/journal` (記録), alongside
|
||||
リーダー and Twitter Lite. These are UI-only prototypes with fictional contacts
|
||||
and in-memory state. Reloading or leaving the route resets the prototype state.
|
||||
No messages, calendar events, medical records, or Rensheng pages are written.
|
||||
|
||||
## Reference and scope
|
||||
|
||||
The functional reference is the user's life brief
|
||||
`briefs/2026-09-25-executive-support-design.md`, together with the public
|
||||
[Beeper Copilot concept](https://beeper-copilot-lp.vercel.app/).
|
||||
The prototype adopts the execution flows, not private facts from the life repo
|
||||
or the reference site's deployment and privacy claims.
|
||||
|
||||
- Communication: unified conversations, a maximum of three initial actions,
|
||||
deferral, waiting, dismissal, and a full list; source messages, summaries,
|
||||
editable reply styles, explicit review, and simulated sending.
|
||||
- Scheduling: choosing a candidate and reviewing a simulated calendar entry.
|
||||
- Context: person notes with links back to the sample conversation and a local
|
||||
record of the simulated outcome. No permanent trait or goal is inferred.
|
||||
- Journal: free notes, resumption points, user-entered medication and weight
|
||||
records; occurrence and recording times, append-only corrections, and a
|
||||
selectable summary preview for a consultation.
|
||||
|
||||
The intended memory boundary remains Rensheng: execution events can eventually
|
||||
be compiled into durable context after source comparison and user correction.
|
||||
This prototype neither implements that compiler nor creates a second personal
|
||||
context database. Missing records remain unknown; there are no medication,
|
||||
dose, weight-target, or treatment recommendations.
|
||||
|
||||
The UI uses existing shadcn Base UI components and neutral theme tokens.
|
||||
Shared account management continues to use the existing connection system;
|
||||
the new communication and journal prototypes do not consume those accounts.
|
||||
@@ -0,0 +1,147 @@
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
const reviewSubject = '来週のレビュー、日程を決める'
|
||||
const coffeeSubject = '久しぶりに、お茶でも'
|
||||
|
||||
test('reviews an edited reply and keeps the simulated result in history', async ({
|
||||
page,
|
||||
a11y,
|
||||
}) => {
|
||||
await page.goto('/support')
|
||||
await page.waitForLoadState('networkidle')
|
||||
const rows = page.locator('.support-row')
|
||||
await expect(rows).toHaveCount(3)
|
||||
await rows.filter({ hasText: reviewSubject }).click()
|
||||
await page.getByRole('button', { name: '丁寧に', exact: true }).click()
|
||||
const reply = page.getByRole('textbox', { name: '返信', exact: true })
|
||||
await expect(reply).toHaveValue(/資料をご共有/)
|
||||
const edited = '資料を確認しました。初回の導線について当日お話しします。'
|
||||
await reply.fill(edited)
|
||||
const slot = '10月5日(月)14:00–14:30'
|
||||
await page.getByRole('combobox', { name: '日程候補' }).selectOption(slot)
|
||||
await page.getByRole('button', { name: '返信に入れる' }).click()
|
||||
await expect(reply).toHaveValue(`${edited}\n\n${slot}はいかがでしょうか。`)
|
||||
await page.getByRole('button', { name: '送信内容を確認' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '送信内容の確認' })
|
||||
await expect(dialog).toContainText(edited)
|
||||
await expect(dialog).toContainText(slot)
|
||||
await expect(dialog).toContainText('外部には送信されません')
|
||||
expect((await a11y().analyze()).violations).toEqual([])
|
||||
await dialog.getByRole('button', { name: '戻る', exact: true }).click()
|
||||
await expect(reply).toHaveValue(new RegExp(edited))
|
||||
await page.getByRole('button', { name: '送信内容を確認' }).click()
|
||||
await dialog.getByRole('button', { name: '送信を試す' }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '履歴', exact: true }).click()
|
||||
await rows.filter({ hasText: reviewSubject }).click()
|
||||
await expect(page.locator('.support-outcome')).toContainText(edited)
|
||||
await expect(page.locator('.support-outcome')).toContainText(slot)
|
||||
})
|
||||
|
||||
test('defers a conversation and restores it to the current queue', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/support')
|
||||
await page.waitForLoadState('networkidle')
|
||||
const rows = page.locator('.support-row')
|
||||
await rows.filter({ hasText: coffeeSubject }).click()
|
||||
await page
|
||||
.getByRole('textbox', { name: '返信', exact: true })
|
||||
.fill('土曜の予定を確認してから返事するね。')
|
||||
await page.getByRole('button', { name: '今は置いておく' }).click()
|
||||
await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: 'あとで', exact: true }).click()
|
||||
await rows.filter({ hasText: coffeeSubject }).click()
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: '返信', exact: true }),
|
||||
).toHaveValue('土曜の予定を確認してから返事するね。')
|
||||
await page.getByRole('button', { name: '今に戻す' }).click()
|
||||
const back = page.getByRole('button', { name: '連絡一覧に戻る' })
|
||||
if (await back.isVisible()) await back.click()
|
||||
await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('button', { name: '今', exact: true }),
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('keeps corrections as new records and summarizes only selected current facts', async ({
|
||||
page,
|
||||
a11y,
|
||||
}) => {
|
||||
await page.goto('/journal')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(
|
||||
page.getByRole('button', { name: '記録する', exact: true }),
|
||||
).toBeDisabled()
|
||||
await page
|
||||
.getByRole('textbox', { name: '記録内容' })
|
||||
.fill('次はレビュー資料の導線を確認する')
|
||||
await page.getByRole('button', { name: '記録する', exact: true }).click()
|
||||
await expect(page.getByRole('article', { name: 'メモの記録' })).toContainText(
|
||||
'発生日時: 不明',
|
||||
)
|
||||
await page.getByRole('button', { name: '体重', exact: true }).click()
|
||||
await page.getByRole('spinbutton', { name: '体重', exact: true }).fill('65.2')
|
||||
await page.getByRole('textbox', { name: '記録元' }).fill('自宅の体重計')
|
||||
await page.getByRole('button', { name: '記録する', exact: true }).click()
|
||||
await page
|
||||
.getByRole('article', { name: '体重の記録' })
|
||||
.getByRole('button', { name: '訂正する' })
|
||||
.click()
|
||||
const correction = page.getByRole('dialog', { name: '記録を訂正' })
|
||||
await correction
|
||||
.getByRole('spinbutton', { name: '体重', exact: true })
|
||||
.fill('65.8')
|
||||
await correction.getByRole('button', { name: '訂正を追加' }).click()
|
||||
const weights = page.getByRole('article', { name: '体重の記録' })
|
||||
await expect(weights).toHaveCount(2)
|
||||
await expect(weights.filter({ hasText: '65.2 kg' })).toContainText('訂正前')
|
||||
await expect(weights.filter({ hasText: '65.8 kg' })).toContainText('訂正')
|
||||
await page
|
||||
.getByRole('button', { name: '記録をまとめる', exact: true })
|
||||
.click()
|
||||
const review = page.getByRole('dialog', { name: '記録をまとめる' })
|
||||
const summary = review.getByRole('textbox', { name: '記録のまとめ' })
|
||||
await expect(summary).toHaveValue(/65.8 kg/)
|
||||
await expect(summary).not.toHaveValue(/65.2 kg/)
|
||||
await expect(summary).toHaveValue(/記録元: 自宅の体重計/)
|
||||
await expect(summary).toHaveValue(/発生日時が不明の記録: 2件/)
|
||||
await review.getByRole('checkbox', { name: /次はレビュー資料/ }).uncheck()
|
||||
await expect(summary).not.toHaveValue(/次はレビュー資料/)
|
||||
await expect(summary).toHaveValue(/記録のない期間の服薬・体重は不明/)
|
||||
expect((await a11y().analyze()).violations).toEqual([])
|
||||
})
|
||||
|
||||
test('fits communication and journal interactions in narrow screens', async ({
|
||||
page,
|
||||
a11y,
|
||||
}) => {
|
||||
for (const width of [320, 375, 768]) {
|
||||
await page.setViewportSize({ width, height: 850 })
|
||||
await page.goto('/support')
|
||||
await page.waitForLoadState('networkidle')
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth),
|
||||
).toBe(width)
|
||||
await page.locator('.support-row').first().click()
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: '返信', exact: true }),
|
||||
).toBeVisible()
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth),
|
||||
).toBe(width)
|
||||
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
|
||||
const back = page.getByRole('button', { name: '連絡一覧に戻る' })
|
||||
if (await back.isVisible()) await back.click()
|
||||
await page.getByRole('link', { name: '記録', exact: true }).click()
|
||||
await page.getByRole('button', { name: '体重', exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole('spinbutton', { name: '体重', exact: true }),
|
||||
).toBeVisible()
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth),
|
||||
).toBe(width)
|
||||
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Link, useLocation } from '@tanstack/react-router'
|
||||
import { Inbox, Layers3, Search } from 'lucide-react'
|
||||
import {
|
||||
Inbox,
|
||||
Layers3,
|
||||
MessagesSquare,
|
||||
NotebookPen,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { Avatar, AvatarFallback } from '#/components/ui/avatar'
|
||||
import {
|
||||
@@ -30,6 +36,18 @@ export function WorkspaceNavigation({
|
||||
const [managingAccounts, setManagingAccounts] = useState(false)
|
||||
const pathname = useLocation({ select: (location) => location.pathname })
|
||||
const navigation = [
|
||||
{
|
||||
title: '連絡',
|
||||
to: '/support',
|
||||
icon: MessagesSquare,
|
||||
active: pathname === '/support',
|
||||
},
|
||||
{
|
||||
title: '記録',
|
||||
to: '/journal',
|
||||
icon: NotebookPen,
|
||||
active: pathname === '/journal',
|
||||
},
|
||||
{
|
||||
title: 'リーダー',
|
||||
to: '/inbox',
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { Dialog } from '#/components/dialog'
|
||||
import { Button } from '#/components/ui/button'
|
||||
import { Checkbox } from '#/components/ui/checkbox'
|
||||
import { Input } from '#/components/ui/input'
|
||||
import { SidebarInset, SidebarProvider } from '#/components/ui/sidebar'
|
||||
import { Textarea } from '#/components/ui/textarea'
|
||||
import { TooltipProvider } from '#/components/ui/tooltip'
|
||||
import { WorkspaceNavigation } from '#/components/workspace-navigation'
|
||||
import './journal.css'
|
||||
|
||||
type EntryKind = 'メモ' | '再開地点' | '服薬' | '体重'
|
||||
type Entry = {
|
||||
id: string
|
||||
kind: EntryKind
|
||||
text: string
|
||||
source: string
|
||||
occurredAt: string
|
||||
recordedAt: string
|
||||
correctionOf?: string
|
||||
}
|
||||
type Draft = Pick<Entry, 'kind' | 'text' | 'source' | 'occurredAt'>
|
||||
const emptyDraft: Draft = { kind: 'メモ', text: '', source: '', occurredAt: '' }
|
||||
const kinds: EntryKind[] = ['メモ', '再開地点', '服薬', '体重']
|
||||
|
||||
function formatTime(value: string | undefined) {
|
||||
if (!value) return '不明'
|
||||
return new Date(value).toLocaleString('ja-JP', {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function JournalPage() {
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [draft, setDraft] = useState<Draft>(emptyDraft)
|
||||
const [correcting, setCorrecting] = useState<Entry | null>(null)
|
||||
const [reviewing, setReviewing] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const superseded = new Set(entries.map((entry) => entry.correctionOf))
|
||||
const current = entries.filter((entry) => !superseded.has(entry.id))
|
||||
|
||||
function append(value: Draft, correctionOf?: string) {
|
||||
setEntries((previous) => [
|
||||
{
|
||||
...value,
|
||||
text: value.text.trim(),
|
||||
source: value.source.trim(),
|
||||
id: crypto.randomUUID(),
|
||||
recordedAt: new Date().toISOString(),
|
||||
correctionOf,
|
||||
},
|
||||
...previous,
|
||||
])
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={500}>
|
||||
<SidebarProvider className="journal-workspace">
|
||||
<WorkspaceNavigation />
|
||||
<SidebarInset className="journal-main">
|
||||
<header className="journal-header">
|
||||
<h1 className="text-base font-semibold">記録</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={current.length === 0}
|
||||
onClick={() => {
|
||||
setSelected(new Set(current.map((entry) => entry.id)))
|
||||
setReviewing(true)
|
||||
}}
|
||||
>
|
||||
記録をまとめる
|
||||
</Button>
|
||||
</header>
|
||||
<div className="journal-scroll">
|
||||
<div className="journal-column">
|
||||
<EntryEditor
|
||||
draft={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={() => {
|
||||
append(draft)
|
||||
setDraft(emptyDraft)
|
||||
}}
|
||||
submitLabel="記録する"
|
||||
/>
|
||||
<section className="journal-entries" aria-label="記録一覧">
|
||||
{entries.length === 0 && (
|
||||
<p className="journal-empty">まだ記録はありません</p>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
<article
|
||||
key={entry.id}
|
||||
className="journal-entry"
|
||||
aria-label={`${entry.kind}の記録`}
|
||||
>
|
||||
<div className="journal-entry-heading">
|
||||
<span className="text-sm font-medium">{entry.kind}</span>
|
||||
{superseded.has(entry.id) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
訂正前
|
||||
</span>
|
||||
)}
|
||||
{entry.correctionOf && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
訂正
|
||||
</span>
|
||||
)}
|
||||
{!superseded.has(entry.id) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => setCorrecting(entry)}
|
||||
>
|
||||
訂正する
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="journal-entry-text">
|
||||
{entry.text}
|
||||
{entry.kind === '体重' ? ' kg' : ''}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
発生日時: {formatTime(entry.occurredAt)} · 記録日時:{' '}
|
||||
{formatTime(entry.recordedAt)}
|
||||
</p>
|
||||
{(entry.kind === '体重' || entry.source) && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
記録元: {entry.source || '不明'}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
{correcting && (
|
||||
<CorrectionDialog
|
||||
entry={correcting}
|
||||
onClose={() => setCorrecting(null)}
|
||||
onSave={(value) => {
|
||||
append(value, correcting.id)
|
||||
setCorrecting(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{reviewing && (
|
||||
<Dialog
|
||||
title="記録をまとめる"
|
||||
className="sm:max-w-2xl"
|
||||
onClose={() => setReviewing(false)}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
含める記録を選んで確認できます。外部には送信されません。
|
||||
</p>
|
||||
<div className="journal-review-list">
|
||||
{current.map((entry) => (
|
||||
<label
|
||||
key={entry.id}
|
||||
className="journal-review-row"
|
||||
htmlFor={`review-${entry.id}`}
|
||||
>
|
||||
<Checkbox
|
||||
id={`review-${entry.id}`}
|
||||
checked={selected.has(entry.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelected((previous) => {
|
||||
const next = new Set(previous)
|
||||
if (checked) next.add(entry.id)
|
||||
else next.delete(entry.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{entry.kind} · {formatTime(entry.occurredAt)}
|
||||
</span>
|
||||
<span className="block text-sm">
|
||||
{entry.text}
|
||||
{entry.kind === '体重' ? ' kg' : ''}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label="記録のまとめ"
|
||||
className="journal-summary"
|
||||
readOnly
|
||||
value={buildSummary(
|
||||
current.filter((entry) => selected.has(entry.id)),
|
||||
)}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function EntryEditor({
|
||||
draft,
|
||||
onChange,
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
}: {
|
||||
draft: Draft
|
||||
onChange: (draft: Draft) => void
|
||||
onSubmit: () => void
|
||||
submitLabel: string
|
||||
}) {
|
||||
const dateId = useId()
|
||||
const valid =
|
||||
draft.text.trim().length > 0 &&
|
||||
(draft.kind !== '体重' ||
|
||||
(Number.isFinite(Number(draft.text)) && Number(draft.text) > 0))
|
||||
return (
|
||||
<form
|
||||
className="journal-editor"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (valid) onSubmit()
|
||||
}}
|
||||
>
|
||||
<fieldset className="journal-kind-switch" aria-label="記録の種類">
|
||||
{kinds.map((kind) => (
|
||||
<Button
|
||||
key={kind}
|
||||
type="button"
|
||||
variant={draft.kind === kind ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
aria-pressed={draft.kind === kind}
|
||||
onClick={() => onChange({ ...draft, kind })}
|
||||
>
|
||||
{kind}
|
||||
</Button>
|
||||
))}
|
||||
</fieldset>
|
||||
{draft.kind === '体重' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0.1"
|
||||
aria-label="体重"
|
||||
placeholder="体重"
|
||||
value={draft.text}
|
||||
onChange={(event) =>
|
||||
onChange({ ...draft, text: event.target.value })
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">kg</span>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
aria-label="記録内容"
|
||||
placeholder={
|
||||
draft.kind === '服薬'
|
||||
? '服用した薬の名前'
|
||||
: draft.kind === '再開地点'
|
||||
? '次にすること、開いておく場所'
|
||||
: '自由なメモ、次にすること'
|
||||
}
|
||||
value={draft.text}
|
||||
onChange={(event) => onChange({ ...draft, text: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
{draft.kind === '体重' && (
|
||||
<Input
|
||||
aria-label="記録元"
|
||||
placeholder="記録元(体重計など・任意)"
|
||||
value={draft.source}
|
||||
onChange={(event) =>
|
||||
onChange({ ...draft, source: event.target.value })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="journal-editor-footer">
|
||||
<label className="journal-date-label" htmlFor={dateId}>
|
||||
発生日時(任意)
|
||||
<Input
|
||||
id={dateId}
|
||||
type="datetime-local"
|
||||
aria-label="発生日時"
|
||||
value={draft.occurredAt}
|
||||
onChange={(event) =>
|
||||
onChange({ ...draft, occurredAt: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" disabled={!valid}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function CorrectionDialog({
|
||||
entry,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
entry: Entry
|
||||
onClose: () => void
|
||||
onSave: (draft: Draft) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Draft>(entry)
|
||||
return (
|
||||
<Dialog title="記録を訂正" onClose={onClose} className="sm:max-w-xl">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
元の記録を残して、訂正を追加します。
|
||||
</p>
|
||||
<EntryEditor
|
||||
draft={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={() => onSave(draft)}
|
||||
submitLabel="訂正を追加"
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function buildSummary(entries: Entry[]) {
|
||||
if (!entries.length) return '記録を選択してください。'
|
||||
const dates = entries
|
||||
.map((entry) => entry.occurredAt)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
const period = dates.length
|
||||
? `${formatTime(dates[0])} 〜 ${formatTime(dates[dates.length - 1])}`
|
||||
: '不明'
|
||||
return [
|
||||
`発生日時の期間: ${period}`,
|
||||
`発生日時が不明の記録: ${entries.filter((entry) => !entry.occurredAt).length}件`,
|
||||
'',
|
||||
...entries.map(
|
||||
(entry) =>
|
||||
`[${entry.kind}] ${entry.text}${entry.kind === '体重' ? ' kg' : ''}\n発生日時: ${formatTime(entry.occurredAt)} / 記録日時: ${formatTime(entry.recordedAt)}${entry.kind === '体重' ? ` / 記録元: ${entry.source || '不明'}` : ''}`,
|
||||
),
|
||||
'',
|
||||
'記録のない期間の服薬・体重は不明です。',
|
||||
].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
.journal-workspace {
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.journal-main {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.journal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 68px;
|
||||
padding: 16px 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.journal-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.journal-column {
|
||||
width: min(100%, 760px);
|
||||
margin: 0 auto;
|
||||
padding: 40px 28px;
|
||||
}
|
||||
.journal-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.journal-kind-switch {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.journal-editor textarea {
|
||||
min-height: 112px;
|
||||
}
|
||||
.journal-editor-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
.journal-date-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
min-width: 0;
|
||||
}
|
||||
.journal-date-label input {
|
||||
max-width: 100%;
|
||||
}
|
||||
.journal-entries {
|
||||
margin-top: 36px;
|
||||
}
|
||||
.journal-empty {
|
||||
padding: 40px 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.journal-entry {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 20px 0;
|
||||
}
|
||||
.journal-entry-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 28px;
|
||||
}
|
||||
.journal-entry-text {
|
||||
margin: 12px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.journal-review-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
.journal-review-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.journal-review-row [data-slot="checkbox"] {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.journal-summary {
|
||||
min-height: 180px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.journal-header {
|
||||
padding: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.journal-column {
|
||||
padding: 24px 14px;
|
||||
}
|
||||
.journal-editor-footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.journal-editor-footer button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
.journal-kind-switch {
|
||||
gap: 0;
|
||||
}
|
||||
.journal-kind-switch button {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { CalendarPlus, Check, ChevronDown, Send } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '#/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '#/components/ui/dialog'
|
||||
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
|
||||
import { Textarea } from '#/components/ui/textarea'
|
||||
import type { Conversation } from './mock-conversations'
|
||||
|
||||
export function ConversationDetail({
|
||||
conversation,
|
||||
onResolve,
|
||||
}: {
|
||||
conversation: Conversation
|
||||
onResolve: (
|
||||
result: 'sent' | 'waiting' | 'later' | 'dismissed',
|
||||
note?: string,
|
||||
) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState(conversation.drafts[0]?.text ?? '')
|
||||
const [tone, setTone] = useState(conversation.drafts[0]?.label ?? '')
|
||||
const [review, setReview] = useState<'send' | 'schedule' | null>(null)
|
||||
const [slot, setSlot] = useState('')
|
||||
const [scheduled, setScheduled] = useState('')
|
||||
|
||||
return (
|
||||
<article
|
||||
className="mx-auto w-full max-w-3xl px-5 py-8 sm:px-10 sm:py-10"
|
||||
aria-label={`${conversation.name}との会話`}
|
||||
>
|
||||
<header className="mb-8 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{conversation.channels.join(' / ')} · {conversation.role}
|
||||
</p>
|
||||
<h1 className="text-2xl font-medium tracking-tight">
|
||||
{conversation.name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{conversation.subject}</p>
|
||||
</header>
|
||||
|
||||
<section className="mb-8 space-y-4" aria-label="会話の要点">
|
||||
<p className="text-sm leading-7">{conversation.summary}</p>
|
||||
<div className="border-l-2 border-border pl-4">
|
||||
<p className="text-sm leading-6">{conversation.nextStep}</p>
|
||||
{conversation.deadline && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
相手の指定期限 · {conversation.deadline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details className="group mb-8 border-y border-border py-3">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
元のやりとり{' '}
|
||||
<ChevronDown className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="space-y-5 pt-5">
|
||||
{conversation.messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
id={`source-${message.id}`}
|
||||
className="scroll-mt-5 rounded-lg bg-muted/40 p-4 focus:outline-none"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{message.direction === 'in' ? conversation.name : '自分'} ·{' '}
|
||||
{message.channel} · {message.time}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap text-sm leading-7">
|
||||
{message.text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<section aria-label="返信を作る" className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label
|
||||
htmlFor={`support-reply-${conversation.id}`}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
返信
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{conversation.replyChannel} → {conversation.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{conversation.drafts.map((option) => (
|
||||
<Button
|
||||
key={option.label}
|
||||
variant={tone === option.label ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
aria-pressed={tone === option.label}
|
||||
onClick={() => {
|
||||
setDraft(option.text)
|
||||
setTone(option.label)
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Textarea
|
||||
id={`support-reply-${conversation.id}`}
|
||||
value={draft}
|
||||
className="min-h-36 resize-y text-sm leading-7"
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value)
|
||||
setTone('')
|
||||
}}
|
||||
/>
|
||||
|
||||
{conversation.scheduleOptions.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<NativeSelect
|
||||
aria-label="日程候補"
|
||||
value={slot}
|
||||
onChange={(event) => setSlot(event.target.value)}
|
||||
>
|
||||
<NativeSelectOption value="">候補日時を選ぶ</NativeSelectOption>
|
||||
{conversation.scheduleOptions.map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{option}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!slot}
|
||||
onClick={() => {
|
||||
setDraft(
|
||||
(current) => `${current}\n\n${slot}はいかがでしょうか。`,
|
||||
)
|
||||
setTone('')
|
||||
}}
|
||||
>
|
||||
返信に入れる
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={!slot}
|
||||
aria-label="予定を仮置きする"
|
||||
onClick={() => setReview('schedule')}
|
||||
>
|
||||
<CalendarPlus />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{scheduled && (
|
||||
<p
|
||||
role="status"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
{scheduled} をこの画面に仮置きしました
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
|
||||
<Button disabled={!draft.trim()} onClick={() => setReview('send')}>
|
||||
<Send />
|
||||
送信内容を確認
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onResolve('later')}
|
||||
>
|
||||
今は置いておく
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onResolve('waiting')}
|
||||
>
|
||||
相手待ち
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onResolve('dismissed')}
|
||||
>
|
||||
返信せず閉じる
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details className="group mt-10 border-t border-border pt-4">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between text-xs text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
人物メモ{' '}
|
||||
<ChevronDown className="size-3 transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-6">
|
||||
{conversation.facts.map((fact) => (
|
||||
<li
|
||||
key={fact.sourceId}
|
||||
className="flex items-start justify-between gap-3"
|
||||
>
|
||||
<span>{fact.text}</span>
|
||||
<a
|
||||
className="shrink-0 text-xs text-muted-foreground underline underline-offset-4"
|
||||
href={`#source-${fact.sourceId}`}
|
||||
onClick={() => {
|
||||
const source = document.getElementById(
|
||||
`source-${fact.sourceId}`,
|
||||
)
|
||||
const details = source?.closest('details')
|
||||
if (details) details.open = true
|
||||
source?.focus()
|
||||
}}
|
||||
>
|
||||
出典
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<Dialog
|
||||
open={review !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setReview(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{review === 'schedule' ? '予定の確認' : '送信内容の確認'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{review === 'schedule'
|
||||
? '外部カレンダーには登録せず、この画面だけに仮置きします。'
|
||||
: '外部には送信されません。完了後の表示を試せます。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<p className="text-sm font-medium">
|
||||
{conversation.name}
|
||||
{review === 'send' && (
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
{conversation.replyChannel}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="max-h-[45dvh] overflow-y-auto whitespace-pre-wrap rounded-lg bg-muted/50 p-4 text-sm leading-7">
|
||||
{review === 'schedule' ? slot : draft}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReview(null)}>
|
||||
戻る
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (review === 'schedule') setScheduled(slot)
|
||||
else
|
||||
onResolve(
|
||||
'sent',
|
||||
`${conversation.replyChannel} → ${conversation.name}(送信プレビュー)\n${draft}`,
|
||||
)
|
||||
setReview(null)
|
||||
}}
|
||||
>
|
||||
{review === 'schedule' ? '仮置きを試す' : '送信を試す'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
type Channel = 'Slack' | 'LINE' | 'Gmail'
|
||||
|
||||
export type Conversation = {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
subject: string
|
||||
channels: Channel[]
|
||||
replyChannel: Channel
|
||||
updatedAt: string
|
||||
deadline?: string
|
||||
summary: string
|
||||
nextStep: string
|
||||
messages: {
|
||||
id: string
|
||||
channel: Channel
|
||||
text: string
|
||||
time: string
|
||||
direction: 'in' | 'out'
|
||||
}[]
|
||||
facts: { text: string; sourceId: string }[]
|
||||
drafts: { label: string; text: string }[]
|
||||
scheduleOptions: string[]
|
||||
}
|
||||
|
||||
// All contacts, conversations and proposed appointments are fictional UI samples.
|
||||
export const conversations: Conversation[] = [
|
||||
{
|
||||
id: 'design-review',
|
||||
name: '青木 遥',
|
||||
role: 'プロダクトデザイナー',
|
||||
subject: '来週のレビュー、日程を決める',
|
||||
channels: ['Slack', 'Gmail'],
|
||||
replyChannel: 'Slack',
|
||||
updatedAt: '10:42',
|
||||
deadline: '9月29日 17:00',
|
||||
summary:
|
||||
'新しい画面のレビュー依頼。資料はメールで届いていて、Slackで日程の返事を待っている。30分あればよい。',
|
||||
nextStep: '資料を確認して、30分の候補日時を返す。',
|
||||
messages: [
|
||||
{
|
||||
id: 'aoki-1',
|
||||
channel: 'Gmail',
|
||||
time: '9月27日 16:20',
|
||||
direction: 'in',
|
||||
text: 'レビュー用の画面案を共有します。特に初回の導線について意見を聞きたいです。確認のお時間は30分ほどを想定しています。',
|
||||
},
|
||||
{
|
||||
id: 'aoki-2',
|
||||
channel: 'Slack',
|
||||
time: '9月28日 10:42',
|
||||
direction: 'in',
|
||||
text: '昨日メールしたレビューの件、来週どこかでお時間いただけますか? 9月29日17時までに候補をいただけると助かります。午後だとうれしいです。',
|
||||
},
|
||||
],
|
||||
facts: [
|
||||
{ text: 'レビューは30分を想定している。', sourceId: 'aoki-1' },
|
||||
{ text: '今回の日程は午後を希望している。', sourceId: 'aoki-2' },
|
||||
],
|
||||
drafts: [
|
||||
{
|
||||
label: '短く',
|
||||
text: '資料ありがとうございます。確認します! 来週の午後で、30分ほどお話しできればと思います。',
|
||||
},
|
||||
{
|
||||
label: '丁寧に',
|
||||
text: '資料をご共有いただきありがとうございます。初回の導線を中心に確認いたします。来週の午後に30分ほどお時間をいただけますでしょうか。',
|
||||
},
|
||||
{
|
||||
label: 'まず受け取る',
|
||||
text: 'ご連絡ありがとうございます。資料、受け取りました。日程を確認して9月29日17時までにお返事します。',
|
||||
},
|
||||
],
|
||||
scheduleOptions: ['10月5日(月)14:00–14:30', '10月6日(火)15:00–15:30'],
|
||||
},
|
||||
{
|
||||
id: 'weekend-coffee',
|
||||
name: '森 直人',
|
||||
role: '友人',
|
||||
subject: '久しぶりに、お茶でも',
|
||||
channels: ['LINE'],
|
||||
replyChannel: 'LINE',
|
||||
updatedAt: '昨日',
|
||||
summary:
|
||||
'近くに来るのでお茶をしようという誘い。急ぎの返事は求められていない。',
|
||||
nextStep: '会いたければ都合を返す。今決めなくてもよい。',
|
||||
messages: [
|
||||
{
|
||||
id: 'mori-1',
|
||||
channel: 'LINE',
|
||||
time: '9月27日 19:15',
|
||||
direction: 'in',
|
||||
text: '来週そっちの方に行くかも。時間合ったらお茶しない? 土曜の午後なら空いてる。急ぎじゃないので、予定わかったら!',
|
||||
},
|
||||
],
|
||||
facts: [{ text: '今回は土曜の午後が空いている。', sourceId: 'mori-1' }],
|
||||
drafts: [
|
||||
{
|
||||
label: '短く',
|
||||
text: 'いいね、久しぶりに会いたい! 土曜の午後で予定見てみるね。',
|
||||
},
|
||||
{
|
||||
label: '丁寧に',
|
||||
text: '誘ってくれてありがとう! ぜひお茶したいです。土曜の午後、都合を確認して連絡するね。',
|
||||
},
|
||||
{
|
||||
label: 'まず受け取る',
|
||||
text: 'ありがとう! まだ予定が見えないので、わかったら連絡するね。',
|
||||
},
|
||||
],
|
||||
scheduleOptions: ['10月3日(土)14:00–15:00', '10月3日(土)16:00–17:00'],
|
||||
},
|
||||
{
|
||||
id: 'estimate-check',
|
||||
name: '藤井 理沙',
|
||||
role: '制作パートナー',
|
||||
subject: '見積もりの前提を確認する',
|
||||
channels: ['Gmail'],
|
||||
replyChannel: 'Gmail',
|
||||
updatedAt: '9:10',
|
||||
deadline: '9月30日 12:00',
|
||||
summary:
|
||||
'見積もりを進めるため、対象画面が3画面でよいか確認が届いている。回答期限は9月30日の正午。',
|
||||
nextStep: '対象画面数を確認して返答する。未確認のまま確約しない。',
|
||||
messages: [
|
||||
{
|
||||
id: 'fujii-1',
|
||||
channel: 'Gmail',
|
||||
time: '9月28日 9:10',
|
||||
direction: 'in',
|
||||
text: '概算の作成にあたり、対象は一覧・詳細・設定の3画面という理解でよろしいでしょうか。9月30日正午までにご確認いただければ、同日中にお見積もりをお送りします。',
|
||||
},
|
||||
],
|
||||
facts: [
|
||||
{ text: '対象画面数の確認後に概算を作成する予定。', sourceId: 'fujii-1' },
|
||||
],
|
||||
drafts: [
|
||||
{
|
||||
label: '短く',
|
||||
text: 'ありがとうございます。対象の3画面について確認し、9月30日正午までにお返事します。',
|
||||
},
|
||||
{
|
||||
label: '丁寧に',
|
||||
text: 'ご確認ありがとうございます。一覧・詳細・設定の3画面で相違ないか確認のうえ、9月30日正午までに回答いたします。',
|
||||
},
|
||||
{
|
||||
label: 'まず受け取る',
|
||||
text: 'ご連絡を受け取りました。対象範囲を確認して、改めてご連絡します。',
|
||||
},
|
||||
],
|
||||
scheduleOptions: [],
|
||||
},
|
||||
{
|
||||
id: 'reading-note',
|
||||
name: '田辺 悠',
|
||||
role: '勉強会の仲間',
|
||||
subject: '前に話していた記事',
|
||||
channels: ['Slack'],
|
||||
replyChannel: 'Slack',
|
||||
updatedAt: '金曜日',
|
||||
summary:
|
||||
'前の勉強会で話題になった記事を共有してくれた。質問や返答の依頼はない。',
|
||||
nextStep: '読む時間がなければ、返信せず閉じてよい。',
|
||||
messages: [
|
||||
{
|
||||
id: 'tanabe-1',
|
||||
channel: 'Slack',
|
||||
time: '9月25日 18:30',
|
||||
direction: 'in',
|
||||
text: '前に話していた、道具を増やさず仕事を進める話の記事を見つけたので共有! 時間あるときにどうぞ。返信不要です。',
|
||||
},
|
||||
],
|
||||
facts: [
|
||||
{ text: 'この共有への返信は不要と明記している。', sourceId: 'tanabe-1' },
|
||||
],
|
||||
drafts: [
|
||||
{ label: '短く', text: '共有ありがとう! あとで読んでみるね。' },
|
||||
{
|
||||
label: '丁寧に',
|
||||
text: '覚えていてくれてありがとう。ちょうど気になっていた話なので、時間を取って読んでみます。',
|
||||
},
|
||||
{ label: 'まず受け取る', text: 'ありがとう、受け取りました!' },
|
||||
],
|
||||
scheduleOptions: [],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,200 @@
|
||||
import { ArrowLeft, Search } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '#/components/ui/button'
|
||||
import { Input } from '#/components/ui/input'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
} from '#/components/ui/sidebar'
|
||||
import { TooltipProvider } from '#/components/ui/tooltip'
|
||||
import { WorkspaceNavigation } from '#/components/workspace-navigation'
|
||||
import { ConversationDetail } from './conversation-detail'
|
||||
import { conversations } from './mock-conversations'
|
||||
import './support.css'
|
||||
|
||||
type Status = 'now' | 'later' | 'waiting' | 'sent' | 'dismissed'
|
||||
const views = [
|
||||
{ id: 'now', label: '今' },
|
||||
{ id: 'later', label: 'あとで' },
|
||||
{ id: 'waiting', label: '相手待ち' },
|
||||
{ id: 'done', label: '履歴' },
|
||||
{ id: 'all', label: 'すべて' },
|
||||
] as const
|
||||
export function SupportPage() {
|
||||
const [view, setView] = useState<string>('now')
|
||||
const [query, setQuery] = useState('')
|
||||
const [statuses, setStatuses] = useState<Record<string, Status>>({})
|
||||
const [selectedId, setSelectedId] = useState<string>()
|
||||
const [reading, setReading] = useState(false)
|
||||
const [outcomes, setOutcomes] = useState<Record<string, string>>({})
|
||||
const [notice, setNotice] = useState('')
|
||||
const matches = conversations.filter((item) => {
|
||||
const status = statuses[item.id] ?? 'now'
|
||||
return (
|
||||
(view === 'all'
|
||||
? true
|
||||
: view === 'done'
|
||||
? status === 'sent' || status === 'dismissed'
|
||||
: status === view) && `${item.name} ${item.subject}`.includes(query)
|
||||
)
|
||||
})
|
||||
const visible = view === 'now' ? matches.slice(0, 3) : matches
|
||||
const selected = visible.find((item) => item.id === selectedId) ?? visible[0]
|
||||
function resolve(id: string, status: Exclude<Status, 'now'>, note?: string) {
|
||||
setStatuses((current) => ({ ...current, [id]: status }))
|
||||
if (note) setOutcomes((current) => ({ ...current, [id]: note }))
|
||||
setNotice(
|
||||
status === 'sent'
|
||||
? '返信の操作を記録しました'
|
||||
: status === 'later'
|
||||
? 'あとでに移しました'
|
||||
: status === 'waiting'
|
||||
? '相手待ちに移しました'
|
||||
: '対応不要にしました',
|
||||
)
|
||||
setReading(false)
|
||||
}
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SidebarProvider className="support-workspace" data-reading={reading}>
|
||||
<Sidebar collapsible="none" className="support-sidebar">
|
||||
<WorkspaceNavigation />
|
||||
<Sidebar collapsible="none" className="support-list">
|
||||
<SidebarHeader className="border-b p-5 gap-4">
|
||||
<h1 className="text-base font-semibold">連絡</h1>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{views.map((tab) => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
size="sm"
|
||||
variant={view === tab.id ? 'secondary' : 'ghost'}
|
||||
aria-pressed={view === tab.id}
|
||||
onClick={() => {
|
||||
setView(tab.id)
|
||||
setSelectedId(undefined)
|
||||
setReading(false)
|
||||
setNotice('')
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="連絡を検索"
|
||||
placeholder="検索"
|
||||
className="pl-9"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="gap-0">
|
||||
{visible.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
className="support-row"
|
||||
aria-current={selected?.id === item.id ? 'true' : undefined}
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id)
|
||||
setReading(true)
|
||||
}}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="font-medium text-sm">{item.subject}</span>
|
||||
</button>
|
||||
))}
|
||||
{!visible.length && (
|
||||
<p className="p-5 text-sm text-muted-foreground">
|
||||
{query ? '見つかりませんでした' : 'いま扱う連絡はありません'}
|
||||
</p>
|
||||
)}
|
||||
{view === 'now' && matches.length > 3 && (
|
||||
<p className="p-5 text-xs text-muted-foreground">
|
||||
ほか {matches.length - 3} 件
|
||||
</p>
|
||||
)}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</Sidebar>
|
||||
<SidebarInset className="support-main">
|
||||
<header className="support-toolbar">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="support-back"
|
||||
aria-label="連絡一覧に戻る"
|
||||
onClick={() => setReading(false)}
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<span className="text-sm">{selected?.name}</span>
|
||||
<span
|
||||
role="status"
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
>
|
||||
{notice}
|
||||
</span>
|
||||
</header>
|
||||
<section
|
||||
className="support-detail"
|
||||
aria-label="連絡の詳細"
|
||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: Independent region needs keyboard scrolling.
|
||||
tabIndex={0}
|
||||
>
|
||||
{conversations.map((conversation) => (
|
||||
<div
|
||||
key={conversation.id}
|
||||
hidden={selected?.id !== conversation.id}
|
||||
>
|
||||
<ConversationDetail
|
||||
conversation={conversation}
|
||||
onResolve={(status, note) =>
|
||||
resolve(conversation.id, status, note)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{selected ? (
|
||||
<>
|
||||
{outcomes[selected.id] && (
|
||||
<div className="support-outcome">
|
||||
<h2 className="font-medium text-sm">活動の記録</h2>
|
||||
<p className="whitespace-pre-wrap mt-2 text-sm">
|
||||
{outcomes[selected.id]}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{view !== 'now' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="m-6"
|
||||
onClick={() => {
|
||||
setStatuses((current) => ({
|
||||
...current,
|
||||
[selected.id]: 'now',
|
||||
}))
|
||||
setView('now')
|
||||
}}
|
||||
>
|
||||
今に戻す
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="support-empty">連絡を選択</div>
|
||||
)}
|
||||
</section>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/* Hallmark · sidebar workbench · neutral · P5 H5 E4 S5 R5 V4 */
|
||||
.support-workspace {
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.support-sidebar {
|
||||
width: 390px;
|
||||
flex-shrink: 0;
|
||||
flex-direction: row;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.support-list {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
}
|
||||
.support-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 22px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.support-row:hover {
|
||||
background: var(--sidebar-accent);
|
||||
}
|
||||
.support-row[aria-current="true"] {
|
||||
background: var(--muted);
|
||||
box-shadow: inset 2px 0 var(--primary);
|
||||
}
|
||||
.support-main {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.support-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 56px;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.support-detail {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.support-detail > * {
|
||||
max-width: 800px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.support-outcome {
|
||||
padding: 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.support-empty {
|
||||
padding: 64px 24px;
|
||||
color: var(--muted-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
.support-back {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.support-sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
.support-main {
|
||||
display: none;
|
||||
}
|
||||
.support-back {
|
||||
display: inline-flex;
|
||||
}
|
||||
.support-workspace[data-reading="true"] .support-sidebar {
|
||||
display: none;
|
||||
}
|
||||
.support-workspace[data-reading="true"] .support-main {
|
||||
display: flex;
|
||||
}
|
||||
.support-toolbar {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,24 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SupportRouteImport } from './routes/support'
|
||||
import { Route as JournalRouteImport } from './routes/journal'
|
||||
import { Route as InboxRouteImport } from './routes/inbox'
|
||||
import { Route as DeckRouteImport } from './routes/deck'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback'
|
||||
import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events'
|
||||
|
||||
const SupportRoute = SupportRouteImport.update({
|
||||
id: '/support',
|
||||
path: '/support',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const JournalRoute = JournalRouteImport.update({
|
||||
id: '/journal',
|
||||
path: '/journal',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const InboxRoute = InboxRouteImport.update({
|
||||
id: '/inbox',
|
||||
path: '/inbox',
|
||||
@@ -45,6 +57,8 @@ export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/deck': typeof DeckRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/journal': typeof JournalRoute
|
||||
'/support': typeof SupportRoute
|
||||
'/api/research/events': typeof ApiResearchEventsRoute
|
||||
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
||||
}
|
||||
@@ -52,6 +66,8 @@ export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/deck': typeof DeckRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/journal': typeof JournalRoute
|
||||
'/support': typeof SupportRoute
|
||||
'/api/research/events': typeof ApiResearchEventsRoute
|
||||
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
||||
}
|
||||
@@ -60,6 +76,8 @@ export interface FileRoutesById {
|
||||
'/': typeof IndexRoute
|
||||
'/deck': typeof DeckRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/journal': typeof JournalRoute
|
||||
'/support': typeof SupportRoute
|
||||
'/api/research/events': typeof ApiResearchEventsRoute
|
||||
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
|
||||
}
|
||||
@@ -69,6 +87,8 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/deck'
|
||||
| '/inbox'
|
||||
| '/journal'
|
||||
| '/support'
|
||||
| '/api/research/events'
|
||||
| '/oauth/mastodon/callback'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -76,6 +96,8 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/deck'
|
||||
| '/inbox'
|
||||
| '/journal'
|
||||
| '/support'
|
||||
| '/api/research/events'
|
||||
| '/oauth/mastodon/callback'
|
||||
id:
|
||||
@@ -83,6 +105,8 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/deck'
|
||||
| '/inbox'
|
||||
| '/journal'
|
||||
| '/support'
|
||||
| '/api/research/events'
|
||||
| '/oauth/mastodon/callback'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -91,12 +115,28 @@ export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
DeckRoute: typeof DeckRoute
|
||||
InboxRoute: typeof InboxRoute
|
||||
JournalRoute: typeof JournalRoute
|
||||
SupportRoute: typeof SupportRoute
|
||||
ApiResearchEventsRoute: typeof ApiResearchEventsRoute
|
||||
OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/support': {
|
||||
id: '/support'
|
||||
path: '/support'
|
||||
fullPath: '/support'
|
||||
preLoaderRoute: typeof SupportRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/journal': {
|
||||
id: '/journal'
|
||||
path: '/journal'
|
||||
fullPath: '/journal'
|
||||
preLoaderRoute: typeof JournalRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/inbox': {
|
||||
id: '/inbox'
|
||||
path: '/inbox'
|
||||
@@ -139,6 +179,8 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
DeckRoute: DeckRoute,
|
||||
InboxRoute: InboxRoute,
|
||||
JournalRoute: JournalRoute,
|
||||
SupportRoute: SupportRoute,
|
||||
ApiResearchEventsRoute: ApiResearchEventsRoute,
|
||||
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { JournalPage } from '#/features/journal/journal-page'
|
||||
|
||||
export const Route = createFileRoute('/journal')({ component: JournalPage })
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { SupportPage } from '#/features/support/support-page'
|
||||
export const Route = createFileRoute('/support')({ component: SupportPage })
|
||||
Reference in New Issue
Block a user