L16 Lotse-Chat für Monteure: mobile Chat-Seite, Aktionskarten, Mikrofon, Einstieg und Einstellung
/m/lotse mit Verlauf, Chips, Sprungzielen, Aktionskarten (Bestätigen/Bearbeiten/Verwerfen), Spracheingabe mit editierbarem Transkript und Offline-Hinweis; Navigationseintrag, Button „Lotse fragen“ im Auftragsdetail, Schalter und Datenfluss in /settings/lotse, Audit-Labels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
"emergency": "Notdienst",
|
||||
"sync": "Sync",
|
||||
"profile": "Profil",
|
||||
"approvalsBadge": "{count} Zeiten zur Freigabe"
|
||||
"approvalsBadge": "{count} Zeiten zur Freigabe",
|
||||
"lotse": "Lotse"
|
||||
},
|
||||
"connection": {
|
||||
"online": "Online",
|
||||
|
||||
+171
-2
@@ -89,6 +89,8 @@
|
||||
"use": "Verwendung",
|
||||
"enabled": "Lotse für diesen Betrieb verwenden",
|
||||
"enabledHint": "Ausgeschaltet: keine Berichtsentwürfe, keine Zusammenfassungen, Sprachnotizen ohne Transkription.",
|
||||
"chatEnabled": "Lotse-Chat für Monteure",
|
||||
"chatEnabledHint": "Monteure schreiben oder sprechen mit dem Lotsen. Aktionen (Status, Zeiten, Material, Notizen) werden nur nach Bestätigung durch den Monteur ausgeführt.",
|
||||
"addressForm": "Anrede des Lotsen",
|
||||
"addressFormHint": "Gilt einheitlich für alle Texte des Lotsen in diesem Betrieb.",
|
||||
"address": {
|
||||
@@ -114,7 +116,9 @@
|
||||
"order": "Auftragstitel, Beschreibung und Leistungsumfang",
|
||||
"notes": "Tätigkeitsnotizen und Texte aus Sprachnotizen",
|
||||
"work": "Checkliste, Material mit Abweichungsgründen, Fotokommentare, Arbeitszeiten",
|
||||
"audio": "Für die Transkription: die Audiodatei der Sprachnotiz"
|
||||
"audio": "Für die Transkription: die Audiodatei der Sprachnotiz",
|
||||
"chat": "Lotse-Chat: Nachrichten des Monteurs (Telefonnummern, E-Mail-Adressen und Anschriften ersetzt) und die Auftragsdaten oben. Kundennamen und Kontaktdaten nur als Platzhalter – eingesetzt wird erst in der Antwort auf dem Gerät des Monteurs.",
|
||||
"chatAudio": "Spracheingabe im Chat: die Aufnahme zur Transkription (wird nicht gespeichert)"
|
||||
},
|
||||
"notSentTitle": "Nicht gesendet wird",
|
||||
"notSent": {
|
||||
@@ -152,7 +156,172 @@
|
||||
"voice_summary": "Zusammenfassung Sprachnotiz",
|
||||
"transcription": "Transkription",
|
||||
"import_extraction": "Auftragsimport",
|
||||
"completeness_check": "Vollständigkeitsprüfung"
|
||||
"completeness_check": "Vollständigkeitsprüfung",
|
||||
"lotse_chat": "Lotse-Chat"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"title": "Lotse-Chat",
|
||||
"intro": "Kurz schreiben oder sprechen, was erledigt ist – z. B. „Auftrag fertig, Speicher installiert, 2 Std., 1 Filter verbaut“. Der Lotse schlägt Aktionen vor. Gespeichert wird erst nach Bestätigung.",
|
||||
"context": "Auftrag {number} · {title}",
|
||||
"noContext": "Ohne Auftragsbezug",
|
||||
"ask": "Lotse fragen",
|
||||
"input": "Nachricht an den Lotsen",
|
||||
"send": "Senden",
|
||||
"sending": "Lotse denkt nach …",
|
||||
"newChat": "Neuer Chat",
|
||||
"userLabel": "Eigene Nachricht",
|
||||
"lotseLabel": "Lotse",
|
||||
"empty": "Noch keine Nachrichten.",
|
||||
"notConfigured": "Lotse ist nicht eingerichtet. Das Büro kann den KI-Anbieter hinterlegen.",
|
||||
"offline": "Keine Verbindung. Der Lotse braucht Internet – die Eingabe bleibt erhalten.",
|
||||
"chipsLabel": "Antwortvorschläge",
|
||||
"linksLabel": "Direkt ergänzen",
|
||||
"confirmAll": "Alle bestätigen ({count})",
|
||||
"mic": {
|
||||
"record": "Sprechen",
|
||||
"stop": "Aufnahme beenden",
|
||||
"recording": "Aufnahme läuft · {time}",
|
||||
"transcribing": "Wird in Text umgewandelt …",
|
||||
"unavailable": "Spracheingabe ist nicht eingerichtet.",
|
||||
"denied": "Kein Zugriff auf das Mikrofon.",
|
||||
"unsupported": "Spracheingabe wird auf diesem Gerät nicht unterstützt.",
|
||||
"failed": "Umwandlung in Text fehlgeschlagen. Bitte tippen.",
|
||||
"hint": "Text vor dem Senden prüfen."
|
||||
},
|
||||
"card": {
|
||||
"confirm": "Bestätigen",
|
||||
"edit": "Bearbeiten",
|
||||
"discard": "Verwerfen",
|
||||
"saveConfirm": "Übernehmen und bestätigen",
|
||||
"cancel": "Abbrechen",
|
||||
"busy": "Wird ausgeführt …",
|
||||
"validUntil": "Gültig bis {time} Uhr",
|
||||
"status": {
|
||||
"proposed": "Offen – bitte bestätigen",
|
||||
"confirmed": "Ausgeführt",
|
||||
"discarded": "Verworfen",
|
||||
"expired": "Abgelaufen",
|
||||
"failed": "Nicht ausgeführt"
|
||||
},
|
||||
"kind": {
|
||||
"transition_work_order": "Status ändern",
|
||||
"book_time": "Zeit buchen",
|
||||
"record_material": "Material erfassen",
|
||||
"add_note": "Notiz speichern",
|
||||
"suggest_report_fields": "Berichtsvorschlag"
|
||||
},
|
||||
"action": {
|
||||
"accept": "Auftrag {number} annehmen",
|
||||
"start_travel": "Anfahrt zu {number} starten",
|
||||
"start_work": "Arbeit an {number} starten",
|
||||
"pause": "Pause bei {number}",
|
||||
"resume": "Arbeit an {number} fortsetzen",
|
||||
"complete": "Auftrag {number} technisch abschließen"
|
||||
},
|
||||
"switchFrom": "Die laufende Uhr von {number} wird pausiert.",
|
||||
"completeHint": "Die eigene Uhr wird beendet. Fehlende Pflichtangaben verhindern den Abschluss.",
|
||||
"order": "Auftrag {number}",
|
||||
"timeLine": "{date} · {from}–{to} Uhr · {duration} Std.",
|
||||
"timeType": {
|
||||
"work": "Arbeitszeit",
|
||||
"travel": "Anfahrt",
|
||||
"return_travel": "Rückfahrt",
|
||||
"material_procurement": "Material holen"
|
||||
},
|
||||
"type": "Art",
|
||||
"date": "Datum",
|
||||
"from": "Von",
|
||||
"to": "Bis",
|
||||
"reason": "Grund",
|
||||
"note": "Notiz",
|
||||
"approvalHint": "Zählt nach Freigabe durch Teamleiter oder Büro.",
|
||||
"pendingApproval": "Zur Freigabe eingereicht",
|
||||
"planned": "Geplante Position",
|
||||
"additional": "Zusatzmaterial",
|
||||
"name": "Bezeichnung",
|
||||
"quantity": "Menge",
|
||||
"unit": "Einheit",
|
||||
"deviationReason": "Grund der Abweichung",
|
||||
"noteKind": "Art der Notiz",
|
||||
"text": "Text",
|
||||
"reportFields": "Vorschläge für den {type}",
|
||||
"reportType": {
|
||||
"daily": "Tagesbericht",
|
||||
"completion": "Abschlussbericht"
|
||||
},
|
||||
"reportHint": "Erscheint im Bericht als „Vorschlag vom Lotsen“ und wird dort übernommen oder verworfen.",
|
||||
"openReport": "Bericht öffnen",
|
||||
"field": {
|
||||
"workPerformed": "Ausgeführte Leistungen",
|
||||
"deviations": "Abweichungen",
|
||||
"additionalWork": "Zusatzarbeiten",
|
||||
"openItems": "Offene Punkte",
|
||||
"nextSteps": "Nächste Schritte",
|
||||
"hints": "Hinweise"
|
||||
}
|
||||
},
|
||||
"noteKind": {
|
||||
"work_done": "Erledigte Arbeit",
|
||||
"deviation": "Abweichung",
|
||||
"problem": "Problem",
|
||||
"additional_work": "Zusatzarbeit",
|
||||
"not_executable": "Nicht ausführbar",
|
||||
"follow_up": "Nacharbeit",
|
||||
"recommendation": "Empfehlung",
|
||||
"customer_note": "Hinweis vom Kunden",
|
||||
"general": "Allgemein"
|
||||
},
|
||||
"system": {
|
||||
"failed": "Nicht ausgeführt ({number}): {reason}",
|
||||
"stopped": "„Alle bestätigen“ gestoppt – {remaining, plural, one {# Karte ist} other {# Karten sind}} noch offen.",
|
||||
"rounds_exceeded": "Das war zu umfangreich. Bitte kürzer oder in Schritten schreiben.",
|
||||
"turn_budget": "Das war zu umfangreich. Bitte kürzer oder in Schritten schreiben.",
|
||||
"refusal": "Dazu kann der Lotse nichts vorschlagen.",
|
||||
"provider_failed": "Lotse ist gerade nicht erreichbar. Bitte später erneut versuchen.",
|
||||
"proposals_ready": "Vorschläge liegen bereit. Bitte prüfen und bestätigen.",
|
||||
"empty": "Keine Antwort erhalten. Bitte anders formulieren."
|
||||
},
|
||||
"links": {
|
||||
"order": "Auftrag öffnen",
|
||||
"report": "Bericht öffnen",
|
||||
"myTime": "Meine Zeiten öffnen",
|
||||
"materials": "Material öffnen",
|
||||
"otherOrder": "Laufenden Auftrag öffnen",
|
||||
"runningSession": "Uhr eines Kollegen läuft noch"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Das hat nicht geklappt. Bitte erneut versuchen.",
|
||||
"not_found": "Nicht gefunden.",
|
||||
"forbidden": "Dafür fehlt die Berechtigung.",
|
||||
"disabled": "Lotse ist für diesen Betrieb ausgeschaltet.",
|
||||
"chat_disabled": "Der Lotse-Chat ist für diesen Betrieb ausgeschaltet.",
|
||||
"not_configured": "Lotse ist nicht eingerichtet.",
|
||||
"provider_failed": "Lotse ist gerade nicht erreichbar. Bitte später erneut versuchen.",
|
||||
"budget_exceeded": "Das monatliche KI-Kontingent des Betriebs ist aufgebraucht.",
|
||||
"invalid": "Bitte Eingaben prüfen.",
|
||||
"conflict": "Inzwischen geändert. Bitte neu laden.",
|
||||
"expired": "Vorschlag abgelaufen (nach 30 Minuten). Bitte neu anfragen.",
|
||||
"already_decided": "Dieser Vorschlag ist schon erledigt.",
|
||||
"tampered": "Der Vorschlag wurde verändert und nicht ausgeführt.",
|
||||
"blocked": "Pflichtangaben fehlen.",
|
||||
"offline": "Keine Verbindung – nichts gesendet.",
|
||||
"overlap": "Der Zeitraum überschneidet sich mit einer erfassten Zeit.",
|
||||
"too_old": "Nachträge sind nur 7 Tage rückwirkend möglich.",
|
||||
"in_future": "Zeiten in der Zukunft können nicht gebucht werden.",
|
||||
"too_long": "Ein Eintrag darf höchstens 16 Stunden lang sein.",
|
||||
"end_before_start": "Das Ende liegt vor dem Beginn.",
|
||||
"work_order_status": "Im aktuellen Auftragsstatus nicht möglich.",
|
||||
"other_session_running": "Auf einem anderen Auftrag läuft noch die Uhr.",
|
||||
"transition_not_allowed": "Dieser Statuswechsel ist gerade nicht möglich.",
|
||||
"transition_forbidden": "Dafür fehlt die Berechtigung.",
|
||||
"not_editable": "Der Bericht ist eingereicht und kann nicht mehr geändert werden.",
|
||||
"reason_required": "Der Grund fehlt.",
|
||||
"use_billing_overview": "Nur über die Abrechnungsübersicht möglich."
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Lotse-Chat nicht verfügbar",
|
||||
"back": "Zur Übersicht"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"emergency": "Emergency",
|
||||
"sync": "Sync",
|
||||
"profile": "Profile",
|
||||
"approvalsBadge": "{count} time entries to approve"
|
||||
"approvalsBadge": "{count} time entries to approve",
|
||||
"lotse": "Lotse"
|
||||
},
|
||||
"connection": {
|
||||
"online": "Online",
|
||||
|
||||
+171
-2
@@ -89,6 +89,8 @@
|
||||
"use": "Usage",
|
||||
"enabled": "Use Lotse in this business",
|
||||
"enabledHint": "Switched off: no report drafts, no summaries, voice notes without transcription.",
|
||||
"chatEnabled": "Lotse chat for technicians",
|
||||
"chatEnabledHint": "Technicians write or talk to the Lotse. Actions (status, times, material, notes) are only executed after the technician confirms them.",
|
||||
"addressForm": "Lotse form of address",
|
||||
"addressFormHint": "Applies consistently to all Lotse texts in this business.",
|
||||
"address": {
|
||||
@@ -114,7 +116,9 @@
|
||||
"order": "Work order title, description and scope",
|
||||
"notes": "Activity notes and voice note texts",
|
||||
"work": "Checklist, material with deviation reasons, photo comments, working times",
|
||||
"audio": "For transcription: the audio file of the voice note"
|
||||
"audio": "For transcription: the audio file of the voice note",
|
||||
"chat": "Lotse chat: the technician's messages (phone numbers, e-mail and postal addresses replaced) and the order data above. Customer names and contact data only as placeholders – values are inserted only in the answer on the technician's device.",
|
||||
"chatAudio": "Voice input in the chat: the recording for transcription (not stored)"
|
||||
},
|
||||
"notSentTitle": "Not sent",
|
||||
"notSent": {
|
||||
@@ -152,7 +156,172 @@
|
||||
"voice_summary": "Voice note summary",
|
||||
"transcription": "Transcription",
|
||||
"import_extraction": "Order import",
|
||||
"completeness_check": "Completeness check"
|
||||
"completeness_check": "Completeness check",
|
||||
"lotse_chat": "Lotse chat"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"title": "Lotse chat",
|
||||
"intro": "Briefly write or say what is done – e.g. “Order finished, storage tank installed, 2 hrs, 1 filter fitted”. The Lotse proposes actions. Nothing is saved before confirmation.",
|
||||
"context": "Order {number} · {title}",
|
||||
"noContext": "No order context",
|
||||
"ask": "Ask the Lotse",
|
||||
"input": "Message to the Lotse",
|
||||
"send": "Send",
|
||||
"sending": "Lotse is thinking …",
|
||||
"newChat": "New chat",
|
||||
"userLabel": "Own message",
|
||||
"lotseLabel": "Lotse",
|
||||
"empty": "No messages yet.",
|
||||
"notConfigured": "The Lotse is not set up. The office can configure the AI provider.",
|
||||
"offline": "No connection. The Lotse needs internet – your input is kept.",
|
||||
"chipsLabel": "Suggested answers",
|
||||
"linksLabel": "Complete directly",
|
||||
"confirmAll": "Confirm all ({count})",
|
||||
"mic": {
|
||||
"record": "Speak",
|
||||
"stop": "Stop recording",
|
||||
"recording": "Recording · {time}",
|
||||
"transcribing": "Converting to text …",
|
||||
"unavailable": "Voice input is not set up.",
|
||||
"denied": "No access to the microphone.",
|
||||
"unsupported": "Voice input is not supported on this device.",
|
||||
"failed": "Conversion to text failed. Please type.",
|
||||
"hint": "Check the text before sending."
|
||||
},
|
||||
"card": {
|
||||
"confirm": "Confirm",
|
||||
"edit": "Edit",
|
||||
"discard": "Discard",
|
||||
"saveConfirm": "Apply and confirm",
|
||||
"cancel": "Cancel",
|
||||
"busy": "Running …",
|
||||
"validUntil": "Valid until {time}",
|
||||
"status": {
|
||||
"proposed": "Open – please confirm",
|
||||
"confirmed": "Done",
|
||||
"discarded": "Discarded",
|
||||
"expired": "Expired",
|
||||
"failed": "Not executed"
|
||||
},
|
||||
"kind": {
|
||||
"transition_work_order": "Change status",
|
||||
"book_time": "Book time",
|
||||
"record_material": "Record material",
|
||||
"add_note": "Save note",
|
||||
"suggest_report_fields": "Report suggestion"
|
||||
},
|
||||
"action": {
|
||||
"accept": "Accept order {number}",
|
||||
"start_travel": "Start travel to {number}",
|
||||
"start_work": "Start work on {number}",
|
||||
"pause": "Pause {number}",
|
||||
"resume": "Resume work on {number}",
|
||||
"complete": "Technically complete order {number}"
|
||||
},
|
||||
"switchFrom": "The running clock of {number} will be paused.",
|
||||
"completeHint": "Your own clock is stopped. Missing mandatory items prevent completion.",
|
||||
"order": "Order {number}",
|
||||
"timeLine": "{date} · {from}–{to} · {duration} hrs",
|
||||
"timeType": {
|
||||
"work": "Working time",
|
||||
"travel": "Travel",
|
||||
"return_travel": "Return travel",
|
||||
"material_procurement": "Fetching material"
|
||||
},
|
||||
"type": "Type",
|
||||
"date": "Date",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"reason": "Reason",
|
||||
"note": "Note",
|
||||
"approvalHint": "Counts after approval by the team lead or office.",
|
||||
"pendingApproval": "Submitted for approval",
|
||||
"planned": "Planned item",
|
||||
"additional": "Additional material",
|
||||
"name": "Name",
|
||||
"quantity": "Quantity",
|
||||
"unit": "Unit",
|
||||
"deviationReason": "Reason for deviation",
|
||||
"noteKind": "Note type",
|
||||
"text": "Text",
|
||||
"reportFields": "Suggestions for the {type}",
|
||||
"reportType": {
|
||||
"daily": "daily report",
|
||||
"completion": "completion report"
|
||||
},
|
||||
"reportHint": "Appears in the report as “Suggestion from the Lotse” and is accepted or discarded there.",
|
||||
"openReport": "Open report",
|
||||
"field": {
|
||||
"workPerformed": "Work performed",
|
||||
"deviations": "Deviations",
|
||||
"additionalWork": "Additional work",
|
||||
"openItems": "Open items",
|
||||
"nextSteps": "Next steps",
|
||||
"hints": "Notes"
|
||||
}
|
||||
},
|
||||
"noteKind": {
|
||||
"work_done": "Work done",
|
||||
"deviation": "Deviation",
|
||||
"problem": "Problem",
|
||||
"additional_work": "Additional work",
|
||||
"not_executable": "Not executable",
|
||||
"follow_up": "Follow-up",
|
||||
"recommendation": "Recommendation",
|
||||
"customer_note": "Customer note",
|
||||
"general": "General"
|
||||
},
|
||||
"system": {
|
||||
"failed": "Not executed ({number}): {reason}",
|
||||
"stopped": "“Confirm all” stopped – {remaining, plural, one {# card is} other {# cards are}} still open.",
|
||||
"rounds_exceeded": "That was too much at once. Please write shorter or in steps.",
|
||||
"turn_budget": "That was too much at once. Please write shorter or in steps.",
|
||||
"refusal": "The Lotse cannot propose anything for this.",
|
||||
"provider_failed": "The Lotse is not reachable right now. Please try again later.",
|
||||
"proposals_ready": "Proposals are ready. Please check and confirm.",
|
||||
"empty": "No answer received. Please rephrase."
|
||||
},
|
||||
"links": {
|
||||
"order": "Open order",
|
||||
"report": "Open report",
|
||||
"myTime": "Open my times",
|
||||
"materials": "Open material",
|
||||
"otherOrder": "Open running order",
|
||||
"runningSession": "A colleague's clock is still running"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "That did not work. Please try again.",
|
||||
"not_found": "Not found.",
|
||||
"forbidden": "You lack the permission.",
|
||||
"disabled": "The Lotse is switched off for this company.",
|
||||
"chat_disabled": "The Lotse chat is switched off for this company.",
|
||||
"not_configured": "The Lotse is not set up.",
|
||||
"provider_failed": "The Lotse is not reachable right now. Please try again later.",
|
||||
"budget_exceeded": "The company's monthly AI quota is used up.",
|
||||
"invalid": "Please check your input.",
|
||||
"conflict": "Changed in the meantime. Please reload.",
|
||||
"expired": "Proposal expired (after 30 minutes). Please ask again.",
|
||||
"already_decided": "This proposal has already been handled.",
|
||||
"tampered": "The proposal was changed and not executed.",
|
||||
"blocked": "Mandatory items are missing.",
|
||||
"offline": "No connection – nothing sent.",
|
||||
"overlap": "The period overlaps with recorded time.",
|
||||
"too_old": "Entries can only be added up to 7 days back.",
|
||||
"in_future": "Times in the future cannot be booked.",
|
||||
"too_long": "An entry may be at most 16 hours long.",
|
||||
"end_before_start": "The end is before the start.",
|
||||
"work_order_status": "Not possible in the current order status.",
|
||||
"other_session_running": "A clock is still running on another order.",
|
||||
"transition_not_allowed": "This status change is not possible right now.",
|
||||
"transition_forbidden": "You lack the permission.",
|
||||
"not_editable": "The report has been submitted and can no longer be changed.",
|
||||
"reason_required": "The reason is missing.",
|
||||
"use_billing_overview": "Only possible via the billing overview."
|
||||
},
|
||||
"unavailable": {
|
||||
"title": "Lotse chat not available",
|
||||
"back": "Back to overview"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,15 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
|
||||
<span className="block text-[12px] text-muted-foreground">{t("settings.enabledHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
{/* L16 Lotse-Chat für Monteure */}
|
||||
<input type="hidden" name="chatEnabledPresent" value="1" />
|
||||
<label className="flex min-h-11 cursor-pointer items-start gap-3">
|
||||
<input type="checkbox" name="chatEnabled" defaultChecked={s.chatEnabled} className="mt-0.5 size-5 accent-[var(--ui-accent)]" />
|
||||
<span>
|
||||
<span className="block text-sm font-semibold">{t("settings.chatEnabled")}</span>
|
||||
<span className="block text-[12px] text-muted-foreground">{t("settings.chatEnabledHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend className="text-sm font-semibold">{t("settings.addressForm")}</legend>
|
||||
<p className="text-[12px] text-muted-foreground">{t("settings.addressFormHint")}</p>
|
||||
@@ -127,7 +136,7 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
|
||||
<div>
|
||||
<h3 className="font-semibold">{t("settings.sentTitle")}</h3>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-5 text-muted-foreground">
|
||||
{(["order", "notes", "work", "audio"] as const).map((k) => (
|
||||
{(["order", "notes", "work", "audio", "chat", "chatAudio"] as const).map((k) => (
|
||||
<li key={k}>{t(`settings.sent.${k}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
|
||||
/** Modul-Gate „lotse" für den Lotse-Chat (L16); „field" gilt bereits über (core)/layout.tsx. */
|
||||
export default async function LotseChatLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
await requireModule("lotse");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ChevronLeft, CircleSlash } from "lucide-react";
|
||||
import type { ChatView } from "@/lib/lotse/chat";
|
||||
import { isAiConfigured } from "@/server/ai/client";
|
||||
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { fieldPageContext } from "@/server/services/field/page-context";
|
||||
import { getChatView } from "@/server/services/lotse/chat/conversations";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
import { LotseMark } from "@/components/lotse/lotse-mark";
|
||||
import { LotseChat } from "@/components/lotse/chat/lotse-chat";
|
||||
import { card } from "@/components/field/ui";
|
||||
|
||||
/**
|
||||
* `/m/lotse` — Lotse chat for technicians (lane L16). `?order=<id>` opens the chat with order context
|
||||
* (button „Lotse fragen" in the order detail). Nothing is executed without confirmation.
|
||||
*/
|
||||
export default async function LotseChatPage({ searchParams }: { searchParams: Promise<{ order?: string }> }) {
|
||||
const [ctx, sp, t, locale] = await Promise.all([fieldPageContext(), searchParams, getTranslations("lotse"), getLocale()]);
|
||||
|
||||
let view: ChatView;
|
||||
try {
|
||||
view = await getChatView(ctx, { workOrderId: sp.order?.trim() || null });
|
||||
} catch (err) {
|
||||
if (!(err instanceof ServiceError)) throw err;
|
||||
if (err.code === "not_found") notFound();
|
||||
const reason = (err.details as { reason?: string } | undefined)?.reason;
|
||||
const code = reason === "disabled" || reason === "chat_disabled" ? reason : err.code === "forbidden" ? "forbidden" : "generic";
|
||||
return (
|
||||
<main className="space-y-3 p-4">
|
||||
<section className={card}>
|
||||
<h1 className="flex items-center gap-2 text-[20px]">
|
||||
<CircleSlash className="size-5 text-muted-foreground" aria-hidden />
|
||||
{t("chat.unavailable.title")}
|
||||
</h1>
|
||||
<p className="mt-2 text-[15px]">{t(`chat.errors.${code}`)}</p>
|
||||
<Link href="/m" className="mt-3 inline-flex min-h-12 items-center font-semibold text-primary">
|
||||
{t("chat.unavailable.back")}
|
||||
</Link>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const timeZone = await tenantTimezone(ctx);
|
||||
return (
|
||||
<main className="space-y-3 p-4">
|
||||
{view.workOrder && (
|
||||
<Link href={`/m/orders/${view.workOrder.id}`} className="-ml-2 inline-flex min-h-12 items-center gap-1 rounded-xl px-2 text-[15px] font-semibold text-primary">
|
||||
<ChevronLeft className="size-5" aria-hidden />
|
||||
{t("chat.context", { number: view.workOrder.number, title: view.workOrder.title })}
|
||||
</Link>
|
||||
)}
|
||||
<h1 className="flex items-center gap-2 text-[22px]">
|
||||
<LotseMark label={t("chat.title")} />
|
||||
</h1>
|
||||
<LotseChat
|
||||
key={view.conversationId ?? `new-${view.workOrder?.id ?? "general"}`}
|
||||
initial={view}
|
||||
workOrderId={view.workOrder?.id ?? null}
|
||||
configured={isAiConfigured()}
|
||||
transcription={transcriptionConfig().configured}
|
||||
locale={locale}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { card, toneClasses } from "@/components/field/ui";
|
||||
import { loadOrder } from "./load";
|
||||
import { LotseCompletenessCard } from "@/components/lotse/completeness-card";
|
||||
import { MilestonesSection } from "@/components/field/milestones";
|
||||
import { LotseAskButton } from "@/components/lotse/chat/ask-button";
|
||||
|
||||
function Section({ title, icon: Icon, children, href, summary }: { title: string; icon: LucideIcon; children?: React.ReactNode; href?: string; summary?: string }) {
|
||||
const head = (
|
||||
@@ -168,6 +169,7 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
|
||||
)}
|
||||
|
||||
{editable && <LotseCompletenessCard workOrderId={order.id} />}
|
||||
{editable && <LotseAskButton ctx={ctx} workOrderId={order.id} />}{/* L16 Lotse-Chat */}
|
||||
|
||||
<MilestonesSection ctx={ctx} workOrderId={order.id} editable={editable} />{/* L14 Abrechnungsübersicht */}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { can } from "@/server/services/context";
|
||||
import { fieldPageContext } from "@/server/services/field/page-context";
|
||||
import { getMyActiveSession } from "@/server/services/field/sessions";
|
||||
import { countPendingTimeEntries } from "@/server/services/field/time-entries";
|
||||
import { canUseLotseChat } from "@/server/services/lotse/chat/access";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
|
||||
@@ -14,19 +15,20 @@ import { RunningClockBar, type ClockSession } from "@/components/field/running-c
|
||||
import { OfflineRuntime } from "@/components/offline/offline-runtime";
|
||||
|
||||
/** L12: own running/paused session + open approvals for the shell (never blocks the page). */
|
||||
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number }> {
|
||||
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean }> {
|
||||
try {
|
||||
const ctx = await fieldPageContext();
|
||||
const [session, approvals] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx)]);
|
||||
const [session, approvals, lotseChat] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx), canUseLotseChat(ctx)]);
|
||||
return {
|
||||
clock: session
|
||||
? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds }
|
||||
: null,
|
||||
approvals,
|
||||
lotseChat, // L16
|
||||
};
|
||||
} catch {
|
||||
// module "field" disabled or no field permissions: shell without clock/badge
|
||||
return { clock: null, approvals: 0 };
|
||||
return { clock: null, approvals: 0, lotseChat: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +57,7 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
|
||||
</header>
|
||||
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
|
||||
<RunningClockBar initial={time.clock} />
|
||||
<BottomNav approvals={time.approvals} />
|
||||
<BottomNav approvals={time.approvals} lotse={time.lotseChat} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ const ENTITY_LABEL: Record<string, string> = {
|
||||
number_sequence: "Nummernkreis",
|
||||
ai_generation: "Lotse (KI)",
|
||||
lotse_settings: "Lotse-Einstellungen",
|
||||
lotse_chat_message: "Lotse-Chat", lotse_action_proposal: "Lotse-Aktionskarte", lotse_chat_retention: "Lotse-Chat (Aufbewahrung)", // L16
|
||||
};
|
||||
|
||||
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ClipboardList, House, RefreshCw, Siren, User } from "lucide-react";
|
||||
import { ClipboardList, Compass, House, RefreshCw, Siren, User } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ITEMS = [
|
||||
{ href: "/m", key: "today", icon: House, exact: true },
|
||||
{ href: "/m/orders", key: "orders", icon: ClipboardList, exact: false },
|
||||
{ href: "/m/lotse", key: "lotse", icon: Compass, exact: false }, // L16 Lotse-Chat (only when usable)
|
||||
{ href: "/m/emergency", key: "emergency", icon: Siren, exact: false },
|
||||
{ href: "/m/sync", key: "sync", icon: RefreshCw, exact: false },
|
||||
{ href: "/m/profile", key: "profile", icon: User, exact: false },
|
||||
@@ -18,13 +19,13 @@ const ITEMS = [
|
||||
* Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil.
|
||||
* L12: badge counter of open time approvals on „Profil" (number + accessible label).
|
||||
*/
|
||||
export function BottomNav({ approvals = 0 }: { approvals?: number }) {
|
||||
export function BottomNav({ approvals = 0, lotse = false }: { approvals?: number; lotse?: boolean }) {
|
||||
const t = useTranslations("field.nav");
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<nav aria-label={t("label")} className="fixed inset-x-0 bottom-0 z-30 border-t bg-card pb-[env(safe-area-inset-bottom)]">
|
||||
<ul className="mx-auto grid max-w-xl grid-cols-5">
|
||||
{ITEMS.map((item) => {
|
||||
<ul className={cn("mx-auto grid max-w-xl", lotse ? "grid-cols-6" : "grid-cols-5")}>
|
||||
{ITEMS.filter((item) => lotse || item.key !== "lotse").map((item) => {
|
||||
const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
const badge = item.key === "profile" && approvals > 0 ? approvals : 0;
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Compass } from "lucide-react";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { canUseLotseChat } from "@/server/services/lotse/chat/access";
|
||||
import { btnSecondary } from "@/components/field/ui";
|
||||
|
||||
/** „Lotse fragen" in the mobile order detail (lane L16) — opens the chat with this order as context. Hidden when the chat is not usable. */
|
||||
export async function LotseAskButton({ ctx, workOrderId }: { ctx: ServiceCtx; workOrderId: string }) {
|
||||
if (!(await canUseLotseChat(ctx))) return null;
|
||||
const t = await getTranslations("lotse");
|
||||
return (
|
||||
<Link href={`/m/lotse?order=${encodeURIComponent(workOrderId)}`} className={btnSecondary}>
|
||||
<Compass className="size-5 text-[var(--ui-accent)]" aria-hidden />
|
||||
{t("chat.ask")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronRight, CircleCheck, Compass, Info, LoaderCircle, MessageSquarePlus, SendHorizontal, TriangleAlert, WifiOff } from "lucide-react";
|
||||
import { LOTSE_CHAT_IDLE, type ChatMessageView, type ChatView, type LotseChatActionState, type LotseChatErrorCode } from "@/lib/lotse/chat";
|
||||
import { confirmAllProposalsAction, confirmProposalAction, discardProposalAction, newConversationAction, sendChatMessageAction } from "@/server/actions/lotse/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { chip, inputClass, noticeError, noticeWarn } from "@/components/field/ui";
|
||||
import { LotseMark } from "../lotse-mark";
|
||||
import { MicButton } from "./mic-button";
|
||||
import { ProposalCard } from "./proposal-card";
|
||||
|
||||
type Action = (prev: LotseChatActionState, fd: FormData) => Promise<LotseChatActionState>;
|
||||
|
||||
const draftKey = (workOrderId: string | null) => `craftvia:lotse-chat-draft:${workOrderId ?? "general"}`;
|
||||
|
||||
/** next-intl typing does not know dynamic keys with values — loose signature for server-provided keys. */
|
||||
type LooseT = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
function subscribeOnline(cb: () => void) {
|
||||
window.addEventListener("online", cb);
|
||||
window.addEventListener("offline", cb);
|
||||
return () => {
|
||||
window.removeEventListener("online", cb);
|
||||
window.removeEventListener("offline", cb);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lotse chat for technicians (lane L16, `/m/lotse`): history, input with microphone key, chips, jump
|
||||
* links and action cards. Needs a connection — offline the input stays (also in localStorage) and
|
||||
* nothing is sent; there is no offline execution.
|
||||
*/
|
||||
export function LotseChat({
|
||||
initial,
|
||||
workOrderId,
|
||||
configured,
|
||||
transcription,
|
||||
locale,
|
||||
timeZone,
|
||||
}: {
|
||||
initial: ChatView;
|
||||
workOrderId: string | null;
|
||||
configured: boolean;
|
||||
transcription: boolean;
|
||||
locale: string;
|
||||
timeZone: string;
|
||||
}) {
|
||||
const t = useTranslations("lotse");
|
||||
const router = useRouter();
|
||||
const [view, setView] = useState(initial);
|
||||
const [text, setText] = useState("");
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [error, setError] = useState<LotseChatErrorCode | null>(null);
|
||||
const [micError, setMicError] = useState<string | null>(null);
|
||||
const online = useSyncExternalStore(subscribeOnline, () => navigator.onLine, () => true);
|
||||
const [sentText, setSentText] = useState<string | null>(null);
|
||||
const endRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let saved: string | null = null;
|
||||
try {
|
||||
saved = window.localStorage.getItem(draftKey(workOrderId));
|
||||
} catch {
|
||||
// storage unavailable (private mode) — draft only in memory
|
||||
}
|
||||
if (!saved) return;
|
||||
const restore = setTimeout(() => setText((current) => current || saved), 0);
|
||||
return () => clearTimeout(restore);
|
||||
}, [workOrderId]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (text) window.localStorage.setItem(draftKey(workOrderId), text);
|
||||
else window.localStorage.removeItem(draftKey(workOrderId));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [text, workOrderId]);
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [view.messages.length, pending]);
|
||||
|
||||
async function run(key: string, action: Action, values: Record<string, string | null | undefined>): Promise<boolean> {
|
||||
if (!navigator.onLine) {
|
||||
setError("offline");
|
||||
return false;
|
||||
}
|
||||
const fd = new FormData();
|
||||
for (const [k, v] of Object.entries(values)) if (v) fd.set(k, v);
|
||||
setPending(key);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await action(LOTSE_CHAT_IDLE, fd);
|
||||
if (res.status === "ok") {
|
||||
setView(res.view);
|
||||
return true;
|
||||
}
|
||||
if (res.status === "error") {
|
||||
setError(res.code);
|
||||
if (res.view) setView(res.view);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
setError(navigator.onLine ? "generic" : "offline");
|
||||
return false;
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function send(value: string) {
|
||||
const message = value.trim();
|
||||
if (!message || pending) return;
|
||||
setSentText(message);
|
||||
const ok = await run("send", sendChatMessageAction, { text: message, conversationId: view.conversationId, workOrderId });
|
||||
setSentText(null);
|
||||
if (ok && message === text.trim()) setText("");
|
||||
}
|
||||
|
||||
async function confirm(proposalId: string, edits: Record<string, unknown> | null) {
|
||||
const ok = await run(`confirm:${proposalId}`, confirmProposalAction, { proposalId, conversationId: view.conversationId, edits: edits ? JSON.stringify(edits) : null });
|
||||
if (ok) router.refresh(); // clock bar / order status in the shell
|
||||
}
|
||||
|
||||
const lastAssistant = [...view.messages].reverse().find((m) => m.role !== "user");
|
||||
const errorText = error ? (t.has(`chat.errors.${error}`) ? t(`chat.errors.${error}`) : t("chat.errors.generic")) : null;
|
||||
|
||||
function notice(m: ChatMessageView): string | null {
|
||||
const key = m.content.noticeKey;
|
||||
if (!key || !t.has(`chat.system.${key}`)) return null;
|
||||
const values = { ...(m.content.noticeValues ?? {}) } as Record<string, string | number>;
|
||||
if (key === "failed") {
|
||||
const code = String(values.code ?? "generic");
|
||||
values.reason = t.has(`chat.errors.${code}`) ? t(`chat.errors.${code}`) : t("chat.errors.generic");
|
||||
}
|
||||
return (t as unknown as LooseT)(`chat.system.${key}`, values);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[14px] text-muted-foreground">{view.workOrder ? t("chat.context", { number: view.workOrder.number, title: view.workOrder.title }) : t("chat.noContext")}</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending !== null}
|
||||
onClick={() => run("new", newConversationAction, { workOrderId })}
|
||||
className="inline-flex min-h-12 items-center gap-1.5 rounded-xl border border-border bg-card px-3.5 text-[14px] font-semibold text-primary disabled:opacity-50"
|
||||
>
|
||||
<MessageSquarePlus className="size-4.5" aria-hidden />
|
||||
{t("chat.newChat")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{view.messages.length === 0 && (
|
||||
<p className="flex items-start gap-2 rounded-xl border bg-card p-3.5 text-[15px]">
|
||||
<Compass className="mt-0.5 size-5 shrink-0 text-[var(--ui-accent)]" aria-hidden />
|
||||
{t("chat.intro")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ol className="space-y-3" aria-live="polite" aria-relevant="additions">
|
||||
{view.messages.map((m) => {
|
||||
const openCards = m.proposals.filter((p) => p.status === "proposed");
|
||||
const noticeText = notice(m);
|
||||
if (m.role === "user") {
|
||||
return (
|
||||
<li key={m.id} className="flex justify-end">
|
||||
<p className="max-w-[85%] rounded-2xl rounded-br-md bg-primary px-3.5 py-2.5 text-[15px] whitespace-pre-line text-primary-foreground">
|
||||
<span className="sr-only">{t("chat.userLabel")}: </span>
|
||||
{m.text}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (m.role === "tool") {
|
||||
return (
|
||||
<li key={m.id} className="space-y-2">
|
||||
{noticeText && (
|
||||
<p className={m.content.noticeKey === "failed" || m.content.noticeKey === "stopped" ? noticeError : noticeWarn}>
|
||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{noticeText}
|
||||
</p>
|
||||
)}
|
||||
<ChatLinks links={m.content.links} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={m.id} className="space-y-2">
|
||||
<div className="max-w-[92%] rounded-2xl rounded-bl-md border bg-card px-3.5 py-2.5 shadow-card">
|
||||
<LotseMark label={t("chat.lotseLabel")} className="text-[13px]" />
|
||||
{m.text && <p className="mt-1 text-[15px] whitespace-pre-line">{m.text}</p>}
|
||||
{noticeText && (
|
||||
<p className="mt-1 flex items-start gap-1.5 text-[14px] text-muted-foreground">
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
{noticeText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChatLinks links={m.content.links} />
|
||||
{m.proposals.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{m.proposals.map((p) => (
|
||||
<ProposalCard
|
||||
key={p.id}
|
||||
proposal={p}
|
||||
locale={locale}
|
||||
timeZone={timeZone}
|
||||
busy={pending === `confirm:${p.id}` || pending === `all:${m.id}`}
|
||||
disabled={pending !== null}
|
||||
onConfirm={(edits) => confirm(p.id, edits)}
|
||||
onDiscard={() => run(`discard:${p.id}`, discardProposalAction, { proposalId: p.id, conversationId: view.conversationId })}
|
||||
/>
|
||||
))}
|
||||
{openCards.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending !== null}
|
||||
onClick={async () => {
|
||||
if (await run(`all:${m.id}`, confirmAllProposalsAction, { messageId: m.id, conversationId: view.conversationId })) router.refresh();
|
||||
}}
|
||||
className="inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-xl border-2 border-[var(--ui-accent)] bg-card px-4 font-heading text-[15px] font-semibold text-foreground disabled:opacity-50"
|
||||
>
|
||||
{pending === `all:${m.id}` ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <CircleCheck className="size-5" aria-hidden />}
|
||||
{t("chat.confirmAll", { count: openCards.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{m.id === lastAssistant?.id && m.content.chips && m.content.chips.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label={t("chat.chipsLabel")}>
|
||||
{m.content.chips.map((c) => (
|
||||
<button key={c.value} type="button" className={chip(false)} disabled={pending !== null || !configured} onClick={() => send(c.value)}>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{pending === "send" && sentText && (
|
||||
<li className="space-y-2">
|
||||
<div className="flex justify-end">
|
||||
<p className="max-w-[85%] rounded-2xl rounded-br-md bg-primary px-3.5 py-2.5 text-[15px] whitespace-pre-line text-primary-foreground opacity-70">{sentText}</p>
|
||||
</div>
|
||||
<p role="status" className="flex items-center gap-2 text-[14px] font-semibold text-muted-foreground">
|
||||
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
|
||||
{t("chat.sending")}
|
||||
</p>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
<div ref={endRef} />
|
||||
|
||||
{!configured && (
|
||||
<p className={noticeWarn}>
|
||||
<Info className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{t("chat.notConfigured")}
|
||||
</p>
|
||||
)}
|
||||
{!online && (
|
||||
<p className={noticeWarn} role="status">
|
||||
<WifiOff className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{t("chat.offline")}
|
||||
</p>
|
||||
)}
|
||||
{errorText && (
|
||||
<p className={noticeError} role="alert">
|
||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{errorText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="space-y-2 rounded-xl border bg-card p-3 shadow-card"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void send(text);
|
||||
}}
|
||||
>
|
||||
<label htmlFor="lotse-chat-input" className="sr-only">
|
||||
{t("chat.input")}
|
||||
</label>
|
||||
<textarea
|
||||
id="lotse-chat-input"
|
||||
value={text}
|
||||
maxLength={4000}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
void send(text);
|
||||
}
|
||||
}}
|
||||
placeholder={t("chat.input")}
|
||||
className={cn(inputClass, "min-h-24 resize-y py-2.5")}
|
||||
/>
|
||||
{micError && <p className="text-[13.5px] text-[var(--risk)]">{micError}</p>}
|
||||
<div className="flex items-start gap-2">
|
||||
<MicButton enabled={transcription} disabled={pending !== null} onError={setMicError} onText={(value) => setText((current) => (current.trim() ? `${current.trim()} ${value}` : value))} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!configured || pending !== null || !text.trim()}
|
||||
className="inline-flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-cta px-4 font-heading text-[15px] font-semibold text-cta-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
{pending === "send" ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <SendHorizontal className="size-5" aria-hidden />}
|
||||
{t("chat.send")}
|
||||
</button>
|
||||
</div>
|
||||
{transcription && <p className="text-[12.5px] text-muted-foreground">{t("chat.mic.hint")}</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatLinks({ links }: { links?: ChatMessageView["content"]["links"] }) {
|
||||
const t = useTranslations("lotse");
|
||||
if (!links?.length) return null;
|
||||
return (
|
||||
<nav aria-label={t("chat.linksLabel")} className="rounded-xl border bg-card">
|
||||
<ul className="divide-y">
|
||||
{links.map((l) => (
|
||||
<li key={`${l.href}|${l.label ?? l.labelKey}`}>
|
||||
<Link href={l.href} className="flex min-h-12 items-center gap-2 px-3.5 py-2 text-[15px]">
|
||||
<span className="flex-1">{t.has(l.labelKey) ? (t as unknown as LooseT)(l.labelKey, { label: l.label ?? "", text: l.label ?? "" }) : (l.label ?? l.href)}</span>
|
||||
<ChevronRight className="size-4.5 text-muted-foreground" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LoaderCircle, Mic, Square } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_SECONDS = 120;
|
||||
|
||||
function pickMimeType(): string | undefined {
|
||||
if (typeof MediaRecorder === "undefined") return undefined;
|
||||
for (const type of ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]) if (MediaRecorder.isTypeSupported(type)) return type;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const clock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
|
||||
/**
|
||||
* Microphone key of the Lotse chat (lane L16): record (max. 2 min) → POST /api/v1/lotse/transcribe →
|
||||
* the text is handed to the input field, where it can be edited before sending. Without a
|
||||
* configured transcription provider the key is disabled with a hint.
|
||||
*/
|
||||
export function MicButton({ enabled, disabled, onText, onError }: { enabled: boolean; disabled?: boolean; onText: (text: string) => void; onError: (message: string | null) => void }) {
|
||||
const t = useTranslations("lotse.chat.mic");
|
||||
const [state, setState] = useState<"idle" | "recording" | "transcribing">("idle");
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const recorder = useRef<MediaRecorder | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// same detection as the L4 voice recorder (server render: assume support)
|
||||
const supported = typeof window === "undefined" || (typeof MediaRecorder !== "undefined" && !!navigator.mediaDevices?.getUserMedia);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timer.current) clearInterval(timer.current);
|
||||
recorder.current?.stream.getTracks().forEach((tr) => tr.stop());
|
||||
};
|
||||
}, []);
|
||||
|
||||
function stop() {
|
||||
if (timer.current) clearInterval(timer.current);
|
||||
timer.current = null;
|
||||
if (recorder.current?.state === "recording") recorder.current.stop();
|
||||
}
|
||||
|
||||
async function transcribe(blob: Blob) {
|
||||
setState("transcribing");
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append("file", blob, blob.type.includes("mp4") ? "spracheingabe.m4a" : blob.type.includes("ogg") ? "spracheingabe.ogg" : "spracheingabe.webm");
|
||||
const res = await fetch("/api/v1/lotse/transcribe", { method: "POST", body });
|
||||
const data = (await res.json().catch(() => null)) as { text?: string } | null;
|
||||
if (!res.ok || typeof data?.text !== "string") throw new Error("failed");
|
||||
onText(data.text);
|
||||
onError(null);
|
||||
} catch {
|
||||
onError(t("failed"));
|
||||
} finally {
|
||||
setState("idle");
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
onError(null);
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mimeType = pickMimeType();
|
||||
const rec = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
||||
const chunks: BlobPart[] = [];
|
||||
rec.ondataavailable = (e) => e.data.size > 0 && chunks.push(e.data);
|
||||
rec.onstop = () => {
|
||||
stream.getTracks().forEach((tr) => tr.stop());
|
||||
void transcribe(new Blob(chunks, { type: rec.mimeType || mimeType || "audio/webm" }));
|
||||
};
|
||||
recorder.current = rec;
|
||||
rec.start(1000);
|
||||
setSeconds(0);
|
||||
setState("recording");
|
||||
timer.current = setInterval(() => {
|
||||
setSeconds((s) => {
|
||||
if (s + 1 >= MAX_SECONDS) stop();
|
||||
return Math.min(s + 1, MAX_SECONDS);
|
||||
});
|
||||
}, 1000);
|
||||
} catch {
|
||||
onError(t("denied"));
|
||||
}
|
||||
}
|
||||
|
||||
const unavailable = !enabled || !supported;
|
||||
const label = state === "recording" ? t("recording", { time: clock(seconds) }) : state === "transcribing" ? t("transcribing") : t("record");
|
||||
return (
|
||||
<div className="flex flex-col items-stretch gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={state === "recording" ? stop : start}
|
||||
disabled={unavailable || disabled || state === "transcribing"}
|
||||
aria-label={state === "recording" ? t("stop") : t("record")}
|
||||
className={cn(
|
||||
"inline-flex min-h-12 min-w-12 items-center justify-center gap-2 rounded-xl border px-3.5 font-heading text-[15px] font-semibold transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
state === "recording" ? "border-[var(--risk)] bg-[color-mix(in_oklch,var(--risk)_10%,transparent)] text-[var(--risk)]" : "border-border bg-card text-primary hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{state === "recording" ? <Square className="size-5" aria-hidden /> : state === "transcribing" ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Mic className="size-5" aria-hidden />}
|
||||
<span aria-live="polite">{label}</span>
|
||||
</button>
|
||||
{unavailable && <p className="text-[12.5px] text-muted-foreground">{!enabled ? t("unavailable") : t("unsupported")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Ban, CircleCheck, CircleDashed, LoaderCircle, Pencil, TimerOff, TriangleAlert, X } from "lucide-react";
|
||||
import { CHAT_TIME_TYPES, EDITABLE_FIELDS, type ChatProposalView } from "@/lib/lotse/chat";
|
||||
import { NOTE_KINDS } from "@/lib/sync/ops";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { chip, inputClass } from "@/components/field/ui";
|
||||
|
||||
type Values = Record<string, unknown>;
|
||||
|
||||
const STATUS_ICON = { proposed: CircleDashed, confirmed: CircleCheck, discarded: Ban, expired: TimerOff, failed: TriangleAlert } as const;
|
||||
const STATUS_TONE = {
|
||||
proposed: "text-foreground",
|
||||
confirmed: "text-[var(--ok)]",
|
||||
discarded: "text-muted-foreground",
|
||||
expired: "text-muted-foreground",
|
||||
failed: "text-[var(--risk)]",
|
||||
} as const;
|
||||
|
||||
const s = (v: unknown) => (typeof v === "string" ? v : v === null || v === undefined ? "" : String(v));
|
||||
const hm = (minutes: number) => `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}`;
|
||||
|
||||
function fmtDate(key: string, locale: string) {
|
||||
const [y, m, d] = key.split("-").map(Number);
|
||||
if (!y) return key;
|
||||
return new Intl.DateTimeFormat(locale, { weekday: "short", day: "2-digit", month: "2-digit", timeZone: "UTC" }).format(new Date(Date.UTC(y, m - 1, d)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Action card of the Lotse chat (lane L16): clear values, Bestätigen / Bearbeiten / Verwerfen, status
|
||||
* after execution as text + icon (never colour only). Touch targets ≥ 48 px.
|
||||
*/
|
||||
export function ProposalCard({
|
||||
proposal,
|
||||
busy,
|
||||
disabled,
|
||||
locale,
|
||||
timeZone,
|
||||
onConfirm,
|
||||
onDiscard,
|
||||
}: {
|
||||
proposal: ChatProposalView;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
locale: string;
|
||||
timeZone: string;
|
||||
onConfirm: (edits: Values | null) => void;
|
||||
onDiscard: () => void;
|
||||
}) {
|
||||
const t = useTranslations("lotse.chat");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<Values>(proposal.payload);
|
||||
const p = proposal.payload;
|
||||
const open = proposal.status === "proposed";
|
||||
const editable = EDITABLE_FIELDS[proposal.kind].length > 0;
|
||||
const StatusIcon = STATUS_ICON[proposal.status];
|
||||
const set = (key: string, value: unknown) => setDraft((d) => ({ ...d, [key]: value }));
|
||||
const errorText = proposal.errorCode ? (t.has(`errors.${proposal.errorCode}`) ? t(`errors.${proposal.errorCode}`) : t("errors.generic")) : null;
|
||||
const validUntil = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", timeZone }).format(new Date(proposal.expiresAt));
|
||||
|
||||
function submitEdits() {
|
||||
const allowed = EDITABLE_FIELDS[proposal.kind];
|
||||
const edits = Object.fromEntries(Object.entries(draft).filter(([k, v]) => allowed.includes(k) && JSON.stringify(v) !== JSON.stringify(p[k])));
|
||||
onConfirm(Object.keys(edits).length ? edits : null);
|
||||
}
|
||||
|
||||
const label = (text: string, children: React.ReactNode) => (
|
||||
<label className="block space-y-1">
|
||||
<span className="text-[13px] font-semibold text-muted-foreground">{text}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
let body: React.ReactNode = null;
|
||||
let form: React.ReactNode = null;
|
||||
switch (proposal.kind) {
|
||||
case "transition_work_order":
|
||||
body = (
|
||||
<>
|
||||
<p className="text-[16px] font-semibold">{(t as unknown as (key: string, values: Record<string, string>) => string)(`card.action.${s(p.action)}`, { number: s(p.number) })}</p>
|
||||
{s(p.switchFrom) && <p className="text-[14px]">{t("card.switchFrom", { number: s(p.switchFrom) })}</p>}
|
||||
{p.action === "complete" && <p className="text-[13.5px] text-muted-foreground">{t("card.completeHint")}</p>}
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "book_time": {
|
||||
const minutes = Number(p.durationMinutes ?? 0);
|
||||
body = (
|
||||
<>
|
||||
<p className="text-[16px] font-semibold">
|
||||
{t(`card.timeType.${s(p.type)}` as never)} · {t("card.order", { number: s(p.number) })}
|
||||
</p>
|
||||
<p className="text-[15px]">{t("card.timeLine", { date: fmtDate(s(p.date), locale), from: s(p.from), to: s(p.to), duration: hm(minutes) })}</p>
|
||||
<p className="text-[14px]">
|
||||
<span className="text-muted-foreground">{t("card.reason")}: </span>
|
||||
{s(p.reason)}
|
||||
</p>
|
||||
{s(p.note) && (
|
||||
<p className="text-[14px]">
|
||||
<span className="text-muted-foreground">{t("card.note")}: </span>
|
||||
{s(p.note)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[13.5px] text-muted-foreground">{proposal.result?.approvalStatus === "pending" ? t("card.pendingApproval") : t("card.approvalHint")}</p>
|
||||
</>
|
||||
);
|
||||
form = (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label={t("card.type")}>
|
||||
{CHAT_TIME_TYPES.map((type) => (
|
||||
<button key={type} type="button" className={chip(draft.type === type)} aria-pressed={draft.type === type} onClick={() => set("type", type)}>
|
||||
{t(`card.timeType.${type}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{label(t("card.date"), <input type="date" className={inputClass} value={s(draft.date)} onChange={(e) => set("date", e.target.value)} />)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{label(t("card.from"), <input type="time" className={inputClass} value={s(draft.from)} onChange={(e) => set("from", e.target.value)} />)}
|
||||
{label(t("card.to"), <input type="time" className={inputClass} value={s(draft.to)} onChange={(e) => set("to", e.target.value)} />)}
|
||||
</div>
|
||||
{label(t("card.reason"), <input className={inputClass} value={s(draft.reason)} onChange={(e) => set("reason", e.target.value)} />)}
|
||||
{label(t("card.note"), <input className={inputClass} value={s(draft.note)} onChange={(e) => set("note", e.target.value || null)} />)}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "record_material":
|
||||
body = (
|
||||
<>
|
||||
<p className="text-[16px] font-semibold">
|
||||
{s(p.quantity)} {s(p.unit)} · {s(p.name)}
|
||||
</p>
|
||||
<p className="text-[14px] text-muted-foreground">
|
||||
{t("card.order", { number: s(p.number) })} · {p.materialPlanId ? t("card.planned") : t("card.additional")}
|
||||
</p>
|
||||
{s(p.deviationReason) && (
|
||||
<p className="text-[14px]">
|
||||
<span className="text-muted-foreground">{t("card.deviationReason")}: </span>
|
||||
{s(p.deviationReason)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
form = (
|
||||
<div className="space-y-3">
|
||||
{!p.materialPlanId && label(t("card.name"), <input className={inputClass} value={s(draft.name)} onChange={(e) => set("name", e.target.value)} />)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{label(t("card.quantity"), <input type="number" inputMode="decimal" min={0} step="any" className={inputClass} value={s(draft.quantity)} onChange={(e) => set("quantity", e.target.value === "" ? 0 : Number(e.target.value))} />)}
|
||||
{label(t("card.unit"), <input className={inputClass} value={s(draft.unit)} readOnly={Boolean(p.materialPlanId)} onChange={(e) => set("unit", e.target.value)} />)}
|
||||
</div>
|
||||
{label(t("card.deviationReason"), <input className={inputClass} value={s(draft.deviationReason)} onChange={(e) => set("deviationReason", e.target.value || null)} />)}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
case "add_note":
|
||||
body = (
|
||||
<>
|
||||
<p className="text-[14px] text-muted-foreground">
|
||||
{t("card.order", { number: s(p.number) })} · {t(`noteKind.${s(p.kind)}` as never)}
|
||||
</p>
|
||||
<p className="whitespace-pre-line text-[15px]">{s(p.text)}</p>
|
||||
</>
|
||||
);
|
||||
form = (
|
||||
<div className="space-y-3">
|
||||
{label(
|
||||
t("card.noteKind"),
|
||||
<select className={inputClass} value={s(draft.kind)} onChange={(e) => set("kind", e.target.value)}>
|
||||
{NOTE_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{t(`noteKind.${k}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>,
|
||||
)}
|
||||
{label(t("card.text"), <textarea className={cn(inputClass, "min-h-28 py-2.5")} value={s(draft.text)} onChange={(e) => set("text", e.target.value)} />)}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
case "suggest_report_fields": {
|
||||
const fields = (Array.isArray(p.fields) ? p.fields : []) as Array<{ field: string; text: string }>;
|
||||
const draftFields = (Array.isArray(draft.fields) ? draft.fields : []) as Array<{ field: string; text: string }>;
|
||||
body = (
|
||||
<>
|
||||
<p className="text-[16px] font-semibold">
|
||||
{t("card.reportFields", { type: t(`card.reportType.${s(p.reportType)}` as never) })} · {s(p.number)}
|
||||
</p>
|
||||
<dl className="space-y-2">
|
||||
{fields.map((f) => (
|
||||
<div key={f.field}>
|
||||
<dt className="text-[13px] font-semibold text-muted-foreground">{t(`card.field.${f.field}` as never)}</dt>
|
||||
<dd className="whitespace-pre-line text-[15px]">{f.text}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="text-[13.5px] text-muted-foreground">{t("card.reportHint")}</p>
|
||||
{proposal.status === "confirmed" && typeof proposal.result?.href === "string" && (
|
||||
<Link href={proposal.result.href} className="inline-flex min-h-12 items-center font-semibold text-primary underline underline-offset-4">
|
||||
{t("card.openReport")}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
form = (
|
||||
<div className="space-y-3">
|
||||
{draftFields.map((f, i) =>
|
||||
label(
|
||||
t(`card.field.${f.field}` as never),
|
||||
<textarea
|
||||
key={f.field}
|
||||
className={cn(inputClass, "min-h-28 py-2.5")}
|
||||
value={f.text}
|
||||
onChange={(e) => set("fields", draftFields.map((x, j) => (j === i ? { ...x, text: e.target.value } : x)))}
|
||||
/>,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={cn("rounded-xl border border-l-4 bg-card p-3.5 shadow-card", open ? "border-l-[var(--ui-accent)]" : "border-l-border")} aria-label={t(`card.kind.${proposal.kind}`)}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[12.5px] font-bold uppercase tracking-wide text-muted-foreground">{t(`card.kind.${proposal.kind}`)}</span>
|
||||
<span className={cn("inline-flex items-center gap-1 text-[13px] font-semibold", STATUS_TONE[proposal.status])}>
|
||||
<StatusIcon className="size-4" aria-hidden />
|
||||
{t(`card.status.${proposal.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 space-y-1.5">{editing ? form : body}</div>
|
||||
{proposal.status === "failed" && errorText && (
|
||||
<p className="mt-2 flex items-start gap-1.5 text-[14px] text-[var(--risk)]" role="alert">
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
{errorText}
|
||||
</p>
|
||||
)}
|
||||
{open && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || busy}
|
||||
onClick={() => (editing ? submitEdits() : onConfirm(null))}
|
||||
className="inline-flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-cta px-4 font-heading text-[15px] font-semibold text-cta-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
{busy ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <CircleCheck className="size-5" aria-hidden />}
|
||||
{busy ? t("card.busy") : editing ? t("card.saveConfirm") : t("card.confirm")}
|
||||
</button>
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || busy}
|
||||
onClick={() => {
|
||||
setDraft(p);
|
||||
setEditing((e) => !e);
|
||||
}}
|
||||
className="inline-flex min-h-12 items-center justify-center gap-1.5 rounded-xl border border-border bg-card px-3.5 text-[15px] font-semibold text-primary disabled:opacity-50"
|
||||
>
|
||||
<Pencil className="size-4.5" aria-hidden />
|
||||
{editing ? t("card.cancel") : t("card.edit")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || busy}
|
||||
onClick={onDiscard}
|
||||
className="inline-flex min-h-12 items-center justify-center gap-1.5 rounded-xl border border-border bg-card px-3.5 text-[15px] font-semibold text-primary disabled:opacity-50"
|
||||
>
|
||||
<X className="size-4.5" aria-hidden />
|
||||
{t("card.discard")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("card.validUntil", { time: validUntil })}</p>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user