feat(job-completion): add message and photo upload to job completion

This commit is contained in:
2026-01-13 11:41:21 +01:00
parent e104a9d377
commit 97d8f12fc0
14 changed files with 265 additions and 25 deletions

View File

@@ -76,21 +76,25 @@ Mit **Docker CLI**:
# Image bauen
docker build -t cat-sitting-planner .
# Container starten
# Container starten
docker run -d \
--name cat-sitting-planner \
-p 3000:3000 \
-v /pfad/zum/host/data:/app/data \
-v /pfad/zum/host/uploads:/app/public/uploads \
--restart always \
cat-sitting-planner
```
### 2. Datenpersistenz (Wichtig)
Die Daten liegen in `/app/data/dev.db`.
Mappe diesen Ordner unbedingt auf ein lokales Volume:
Bilder werden in `/app/public/uploads` gespeichert.
Mappe diese Ordner unbedingt auf lokale Volumes:
```yaml
volumes:
- /pfad/zum/host/data:/app/data
- /pfad/zum/host/uploads:/app/public/uploads
```
---

View File

@@ -16,6 +16,10 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { uploadImage } from "@/app/actions/upload-image"
import { Camera, Upload } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
@@ -61,12 +65,56 @@ export function PlanDashboard({ plan, dict, settingsDict, lang }: PlanDashboardP
const [cancelReason, setCancelReason] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const handleComplete = async (bookingId: number) => {
// Completion Dialog State
const [isCompleteDialogOpen, setIsCompleteDialogOpen] = useState(false)
const [bookingToComplete, setBookingToComplete] = useState<number | null>(null)
const [completionMessage, setCompletionMessage] = useState("")
const [completionImage, setCompletionImage] = useState<File | null>(null)
const [imagePreview, setImagePreview] = useState<string | null>(null)
const handleCompleteClick = (bookingId: number) => {
setBookingToComplete(bookingId)
setCompletionMessage("")
setCompletionImage(null)
setImagePreview(null)
setIsCompleteDialogOpen(true)
}
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setCompletionImage(file)
const reader = new FileReader()
reader.onloadend = () => {
setImagePreview(reader.result as string)
}
reader.readAsDataURL(file)
}
}
const handleConfirmComplete = async () => {
if (!bookingToComplete) return
setIsSubmitting(true)
try {
await completeBooking(bookingId, plan.id, lang)
toast.success(dict.bookedSuccess) // reuse for now or add new toast
let imageUrl: string | undefined = undefined
if (completionImage) {
const formData = new FormData()
formData.append("file", completionImage)
formData.append("planId", plan.id)
imageUrl = await uploadImage(formData)
}
await completeBooking(bookingToComplete, plan.id, lang, completionMessage, imageUrl)
toast.success(dict.jobDone)
setIsCompleteDialogOpen(false)
setBookingToComplete(null)
} catch (error) {
console.error(error)
toast.error(dict.bookError)
} finally {
setIsSubmitting(false)
}
}
@@ -238,7 +286,7 @@ export function PlanDashboard({ plan, dict, settingsDict, lang }: PlanDashboardP
variant="outline"
size="sm"
className="w-full flex gap-2 items-center border-green-200 hover:bg-green-100/50 text-green-700 dark:border-green-800 dark:hover:bg-green-900/40 font-semibold"
onClick={() => handleComplete(booking.id)}
onClick={() => handleCompleteClick(booking.id)}
>
<Check className="w-4 h-4" />
{dict.markDone}
@@ -352,6 +400,72 @@ export function PlanDashboard({ plan, dict, settingsDict, lang }: PlanDashboardP
</DialogFooter>
</DialogContent>
</Dialog>
{/* Completion Dialog */}
<Dialog open={isCompleteDialogOpen} onOpenChange={setIsCompleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{dict.completeTitle}</DialogTitle>
<DialogDescription>
{dict.completeDesc}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="message">{dict.completeMessage}</Label>
<Textarea
id="message"
value={completionMessage}
onChange={(e) => setCompletionMessage(e.target.value)}
placeholder={dict.completeMessagePlaceholder}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="photo">{dict.completePhoto}</Label>
<div className="flex items-center gap-4">
<Button variant="outline" size="icon" className="w-12 h-12" onClick={() => document.getElementById('photo-upload')?.click()}>
<Camera className="w-6 h-6" />
</Button>
<Input
id="photo-upload"
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={handleImageChange}
/>
{imagePreview && (
<div className="relative w-20 h-20 rounded-md overflow-hidden border">
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
<Button
variant="ghost"
size="icon"
className="absolute top-0 right-0 h-5 w-5 bg-background/50 hover:bg-background"
onClick={() => {
setCompletionImage(null)
setImagePreview(null)
}}
>
<X className="w-3 h-3" />
</Button>
</div>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCompleteDialogOpen(false)} disabled={isSubmitting}>
{dict.cancel}
</Button>
<Button onClick={handleConfirmComplete} disabled={isSubmitting}>
{isSubmitting ? dict.completing : dict.completeSubmit}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -83,7 +83,7 @@ export async function deleteBooking(bookingId: number, planId: string, lang: str
revalidatePath(`/${lang}/dashboard/${planId}`)
}
export async function completeBooking(bookingId: number, planId: string, lang: string = "en") {
export async function completeBooking(bookingId: number, planId: string, lang: string = "en", message?: string, imageUrl?: string) {
const dict = await getDictionary(lang as any)
const booking = await prisma.booking.findUnique({
@@ -95,7 +95,11 @@ export async function completeBooking(bookingId: number, planId: string, lang: s
await prisma.booking.update({
where: { id: bookingId },
data: { completedAt: new Date() }
data: {
completedAt: new Date(),
completionMessage: message,
completionImage: imageUrl
}
})
if (booking.plan.notifyAll) {
@@ -104,12 +108,19 @@ export async function completeBooking(bookingId: number, planId: string, lang: s
const planUrl = `${protocol}://${host}/${lang}/dashboard/${planId}`
const dateStr = booking.date.toLocaleDateString(lang)
const message = dict.notifications.completed
let notificationMessage = dict.notifications.completed
.replace("{name}", booking.sitterName || "Someone")
.replace("{date}", dateStr)
.replace("{url}", planUrl)
await sendPlanNotification(planId, message, booking.plan.webhookUrl)
if (message) {
notificationMessage += `\n"${message}"`
}
// We need to pass the full URL for the image
const fullImageUrl = imageUrl ? `${protocol}://${host}${imageUrl}` : undefined
await sendPlanNotification(planId, notificationMessage, booking.plan.webhookUrl, fullImageUrl)
}
revalidatePath(`/${lang}/dashboard/${planId}`)

View File

@@ -0,0 +1,50 @@
"use server"
import { writeFile, mkdir } from "fs/promises"
import { join } from "path"
import { v4 as uuidv4 } from "uuid"
export async function uploadImage(data: FormData) {
const file: File | null = data.get("file") as unknown as File
if (!file) {
throw new Error("No file uploaded")
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Validate mime type (basic check)
if (!file.type.startsWith("image/")) {
throw new Error("Only images are allowed")
}
// Create uploads directory if it doesn't exist
// We organize by generic uploads folder for now, or per plan?
// Let's optimize for simplicity: /public/uploads/YYYY/MM
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const params = data.get("planId") ? String(data.get("planId")) : "misc"
// Use /uploads/planId/ to keep it somewhat organized or cleanup-able
const relativeDir = join("uploads", params)
const uploadDir = join(process.cwd(), "public", relativeDir)
try {
await mkdir(uploadDir, { recursive: true })
} catch (e) {
// ignore if exists
}
// Generate filename
const ext = file.name.split('.').pop() || "jpg"
const filename = `${uuidv4()}.${ext}`
const filepath = join(uploadDir, filename)
await writeFile(filepath, buffer)
// Return the public URL
return `/${relativeDir}/${filename}`
}

View File

@@ -62,7 +62,14 @@
"cancelError": "Entfernen fehlgeschlagen",
"noInstructions": "Noch keine spezifischen Instruktionen hinterlegt.",
"markDone": "Job erledigt?",
"jobDone": "Job erledigt!"
"jobDone": "Job erledigt!",
"completeTitle": "Job abschließen",
"completeDesc": "Füge optional eine Nachricht oder ein Foto hinzu.",
"completeMessage": "Nachricht (optional)",
"completeMessagePlaceholder": "Alles super!",
"completePhoto": "Foto (optional)",
"completeSubmit": "Job als erledigt markieren",
"completing": "Speichere..."
},
"login": {
"title": "Passwort eingeben",

View File

@@ -62,7 +62,14 @@
"cancelError": "Cancellation failed",
"noInstructions": "No specific instructions added yet.",
"markDone": "Job done?",
"jobDone": "Job done!"
"jobDone": "Job done!",
"completeTitle": "Complete Job",
"completeDesc": "Add a message or photo (optional) and confirm.",
"completeMessage": "Message (optional)",
"completeMessagePlaceholder": "All good!",
"completePhoto": "Photo (optional)",
"completeSubmit": "Complete Job",
"completing": "Completing..."
},
"login": {
"title": "Enter Password",

View File

@@ -7,4 +7,5 @@ services:
- DATABASE_URL=file:/app/data/dev.db
volumes:
- ./data:/app/data
- ./public/uploads:/app/public/uploads
restart: always

View File

@@ -1,18 +1,37 @@
export async function sendNotification(webhookUrl: string | null, message: string) {
export async function sendNotification(webhookUrl: string | null, message: string, imageUrl?: string) {
if (!webhookUrl) return;
try {
const payload: any = {
content: message,
text: message
}
// For Discord: use embed if image exists
if (imageUrl && webhookUrl.includes("discord")) {
payload.embeds = [{
image: {
url: imageUrl
}
}]
}
// For generic webhooks, just append URL to text if not Discord, or leave as is?
// Let's create a simpler payload if image exists but not discord?
// Actually, if we just send JSON, most won't render it unless specific format.
// Let's just append the image URL to the message if it's not a Discord webhook, so it's clickable.
if (imageUrl && !webhookUrl.includes("discord")) {
payload.content += `\n${imageUrl}`;
payload.text += `\n${imageUrl}`;
}
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "CatSittingPlanner/1.0"
},
// Works for Discord (content) and generic Telegram Webhook bridges (text)
body: JSON.stringify({
content: message,
text: message
}),
body: JSON.stringify(payload),
});
if (!response.ok) {
console.error(`[Notification] Webhook failed with status ${response.status}`);
@@ -25,12 +44,12 @@ export async function sendNotification(webhookUrl: string | null, message: strin
import { sendPushNotification } from "./push";
import prisma from "@/lib/prisma";
export async function sendPlanNotification(planId: string, message: string, webhookUrl?: string | null) {
export async function sendPlanNotification(planId: string, message: string, webhookUrl?: string | null, imageUrl?: string) {
// Parallelize sending
const promises: Promise<any>[] = [];
if (webhookUrl) {
promises.push(sendNotification(webhookUrl, message));
promises.push(sendNotification(webhookUrl, message, imageUrl));
}
try {
@@ -40,13 +59,11 @@ export async function sendPlanNotification(planId: string, message: string, webh
if (subscriptions.length > 0) {
console.log(`[Push] Found ${subscriptions.length} subscriptions for plan ${planId}`);
const payload = {
const payload: any = {
title: "Cat Sitting Planner",
body: message,
url: `/`
// We could pass specific URL if needed, but for now root is okay or dashboard?
// The service worker opens the URL.
// Ideally, we want to open `/dashboard/[planId]`.
url: `/`,
image: imageUrl
};
// Refine URL in payload

21
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-slot": "^1.2.4",
"@types/uuid": "^10.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -30,6 +31,7 @@
"react-hook-form": "^7.71.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"uuid": "^13.0.0",
"web-push": "^3.6.7",
"zod": "^4.3.5"
},
@@ -4332,6 +4334,12 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
@@ -11003,6 +11011,19 @@
}
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/watchpack": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.0.tgz",

View File

@@ -21,6 +21,7 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-slot": "^1.2.4",
"@types/uuid": "^10.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -34,6 +35,7 @@
"react-hook-form": "^7.71.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"uuid": "^13.0.0",
"web-push": "^3.6.7",
"zod": "^4.3.5"
},
@@ -50,4 +52,4 @@
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}
}

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "completionImage" TEXT;
ALTER TABLE "Booking" ADD COLUMN "completionMessage" TEXT;

View File

@@ -34,6 +34,9 @@ model Booking {
completedAt DateTime?
createdAt DateTime @default(now())
completionMessage String?
completionImage String?
@@unique([planId, date, sitterName])
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB