Add attachments to service, maintenance, fuel and parts records
Documents already carried a single optional file: upload, download, detach,
access-checked on every request and proxied through this server, so PocketBase's
files are never public URLs. Service records, workshop visits, refills and
catalog parts all want the same thing — a receipt, an invoice, a photo of the
box — so extend it to them.
Rather than copy the document handlers four more times, lift them into one
shared layer. Every attachable collection has a car relation and a file field,
which is what lets a single set of handlers authorize and serve all of them. An
upload finishes by delegating to the collection's own GET handler, so the
response carries the full record — derived fields and all — exactly as a re-read
would. The web side gets the same treatment: one picker component and one
upload-after-save helper behind all five forms. Net effect is five features for
about the cost of the one that was already there.
Alongside:
- parts gain a notes field
- Reminders moves behind Parts catalog in the car detail tabs
- the changed-part labels spell out in full ("Oil & Oil filter" rather than
"Oil & filter"), and the form and table now agree
The PocketBase schema must be migrated before the new attachments work:
scripts/setup-pocketbase.mjs adds the file fields and parts.notes. It is
additive and safe to re-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e64c89a564
commit
07192f1238
@@ -0,0 +1,161 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Single-file attachments. A document has a scan, a service or a refill has a
|
||||
// receipt, a workshop visit has an invoice, a catalog part has a photo of the
|
||||
// box — all the same thing: one optional file hanging off a record that already
|
||||
// exists.
|
||||
//
|
||||
// The terms are identical everywhere, so the endpoints are registered from one
|
||||
// place (attachmentRoutes) rather than copied per collection. The bytes live in
|
||||
// PocketBase's file storage and are reached only through this server's superuser
|
||||
// service account, so an attachment is never a public URL: clients fetch it from
|
||||
// GET /api/{records}/{id}/file, which re-checks car access on every request.
|
||||
|
||||
// maxAttachmentUpload caps an attachment at 10 MB — comfortably above a scanned
|
||||
// certificate or a photographed receipt, well below anything that would tie up
|
||||
// the server. Mirrored by the collections' own maxSize in setup-pocketbase.mjs.
|
||||
const maxAttachmentUpload = 10 << 20
|
||||
|
||||
// attachmentFileTypes are the extensions an attachment may carry. The list is
|
||||
// restrictive on purpose: these are scans and photos, and anything executable
|
||||
// has no business being stored and handed back out.
|
||||
var attachmentFileTypes = map[string]bool{
|
||||
".pdf": true, ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".heic": true,
|
||||
}
|
||||
|
||||
// attachedRecord is the slice of an attachable record these handlers need: the
|
||||
// parent car to authorize against, and the stored file name. Every attachable
|
||||
// collection has both, which is what lets one handler serve all of them.
|
||||
type attachedRecord struct {
|
||||
ID string `json:"id"`
|
||||
Car string `json:"car"`
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
// attachmentRoutes registers the upload/download/detach endpoints for one
|
||||
// collection under prefix (e.g. "/api/service-records").
|
||||
//
|
||||
// respond is the collection's own GET handler. An upload finishes by delegating
|
||||
// to it, so the response carries the full record in its own model shape —
|
||||
// derived fields and all — exactly as a re-read would.
|
||||
func (s *Server) attachmentRoutes(mux *http.ServeMux, prefix, collection string, respond http.HandlerFunc) {
|
||||
mux.HandleFunc("POST "+prefix+"/{id}/file", s.handleUploadAttachment(collection, respond))
|
||||
mux.HandleFunc("GET "+prefix+"/{id}/file", s.handleGetAttachment(collection))
|
||||
mux.HandleFunc("DELETE "+prefix+"/{id}/file", s.handleDeleteAttachment(collection))
|
||||
}
|
||||
|
||||
// handleUploadAttachment serves POST /api/{records}/{id}/file, replacing
|
||||
// whatever was attached before.
|
||||
func (s *Server) handleUploadAttachment(collection string, respond http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
existing, ok := s.attachmentTarget(w, r, collection, accessWrite)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(maxAttachmentUpload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "upload must be under 10MB")
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "missing file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxAttachmentUpload+1))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not read upload")
|
||||
return
|
||||
}
|
||||
if len(data) > maxAttachmentUpload {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "upload must be under 10MB")
|
||||
return
|
||||
}
|
||||
if msg := validateAttachmentFile(header.Filename); msg != "" {
|
||||
writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.pb.UpdateMultipart(r.Context(), collection, existing.ID, nil, "file", header.Filename, data); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
respond(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetAttachment serves GET /api/{records}/{id}/file. The bytes are proxied
|
||||
// through this server because PocketBase's collections have no public access
|
||||
// rules — only the service account can read them.
|
||||
func (s *Server) handleGetAttachment(collection string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := s.attachmentTarget(w, r, collection, accessRead)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if rec.File == "" {
|
||||
writeError(w, http.StatusNotFound, "no file attached")
|
||||
return
|
||||
}
|
||||
data, contentType, err := s.pb.GetFile(r.Context(), collection, rec.ID, rec.File)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
// The stored name is PocketBase's slugified one; quotes are stripped so a
|
||||
// crafted filename can't break out of the header.
|
||||
name := strings.ReplaceAll(rec.File, `"`, "")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
w.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteAttachment serves DELETE /api/{records}/{id}/file, detaching the
|
||||
// file but keeping the record itself.
|
||||
func (s *Server) handleDeleteAttachment(collection string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
existing, ok := s.attachmentTarget(w, r, collection, accessWrite)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.pb.Update(r.Context(), collection, existing.ID, map[string]any{"file": nil}, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// attachmentTarget loads the record named by the {id} path value and authorizes
|
||||
// the caller against its car. It writes the error response itself; ok=false
|
||||
// means the caller must return without writing anything further.
|
||||
func (s *Server) attachmentTarget(w http.ResponseWriter, r *http.Request, collection, need string) (attachedRecord, bool) {
|
||||
var rec attachedRecord
|
||||
if err := s.pb.GetOne(r.Context(), collection, r.PathValue("id"), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return rec, false
|
||||
}
|
||||
if !s.requireCarAccess(w, r, rec.Car, need) {
|
||||
return rec, false
|
||||
}
|
||||
return rec, true
|
||||
}
|
||||
|
||||
func validateAttachmentFile(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if !attachmentFileTypes[ext] {
|
||||
return "file must be a PDF or an image (pdf, jpg, png, webp, heic)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -3,10 +3,8 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -22,14 +20,8 @@ import (
|
||||
// (models.CarDocument.ComputeExpiry) rather than stored and left to go stale.
|
||||
// Documents also feed the auto-derived reminders in reminders.go.
|
||||
//
|
||||
// The scan/PDF lives in PocketBase's file storage and is reached only through
|
||||
// this server's superuser service account, so an attachment is never a public
|
||||
// URL: clients fetch it from GET /api/car-documents/{id}/file, which re-checks
|
||||
// car access on every request.
|
||||
|
||||
// maxDocumentUpload caps an attachment at 10 MB — comfortably above a scanned
|
||||
// certificate, well below anything that would tie up the server.
|
||||
const maxDocumentUpload = 10 << 20
|
||||
// The scan/PDF attached to a document is handled by attachments.go, on the same
|
||||
// terms as every other record's attachment.
|
||||
|
||||
var documentTypes = map[string]bool{
|
||||
"insurance": true,
|
||||
@@ -196,104 +188,6 @@ func (s *Server) deleteDocument(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleUploadDocumentFile serves POST /api/car-documents/{id}/file — the
|
||||
// scan/PDF for an existing document. Replaces whatever was attached before.
|
||||
func (s *Server) handleUploadDocumentFile(w http.ResponseWriter, r *http.Request) {
|
||||
var existing documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(maxDocumentUpload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "document upload must be under 10MB")
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "missing file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxDocumentUpload+1))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not read upload")
|
||||
return
|
||||
}
|
||||
if len(data) > maxDocumentUpload {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "document upload must be under 10MB")
|
||||
return
|
||||
}
|
||||
if msg := validateDocumentFile(header.Filename); msg != "" {
|
||||
writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.pb.UpdateMultipart(r.Context(), colDocuments, existing.ID, nil, "file", header.Filename, data); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var rec documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, existing.ID, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
d := rec.toModel()
|
||||
d.ComputeExpiry(time.Now())
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
// handleGetDocumentFile serves GET /api/car-documents/{id}/file. The bytes are
|
||||
// proxied through this server because PocketBase's collections have no public
|
||||
// access rules — only the service account can read them.
|
||||
func (s *Server) handleGetDocumentFile(w http.ResponseWriter, r *http.Request) {
|
||||
var rec documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, rec.Car, accessRead) {
|
||||
return
|
||||
}
|
||||
if rec.File == "" {
|
||||
writeError(w, http.StatusNotFound, "no file attached")
|
||||
return
|
||||
}
|
||||
data, contentType, err := s.pb.GetFile(r.Context(), colDocuments, rec.ID, rec.File)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
// The stored name is PocketBase's slugified one; quotes are stripped so a
|
||||
// crafted filename can't break out of the header.
|
||||
name := strings.ReplaceAll(rec.File, `"`, "")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// handleDeleteDocumentFile serves DELETE /api/car-documents/{id}/file, detaching
|
||||
// the attachment but keeping the document's metadata.
|
||||
func (s *Server) handleDeleteDocumentFile(w http.ResponseWriter, r *http.Request) {
|
||||
var existing documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
if err := s.pb.Update(r.Context(), colDocuments, existing.ID, map[string]any{"file": nil}, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func validateDocument(d models.CarDocument) string {
|
||||
switch {
|
||||
case d.Car == "":
|
||||
@@ -310,18 +204,3 @@ func validateDocument(d models.CarDocument) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// documentFileTypes are the extensions an attachment may carry. The list is
|
||||
// restrictive on purpose: these documents are scans, and anything executable has
|
||||
// no business being stored and handed back out.
|
||||
var documentFileTypes = map[string]bool{
|
||||
".pdf": true, ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".heic": true,
|
||||
}
|
||||
|
||||
func validateDocumentFile(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if !documentFileTypes[ext] {
|
||||
return "file must be a PDF or an image (pdf, jpg, png, webp, heic)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ type serviceRecord struct {
|
||||
ChangedEngineAirFilter bool `json:"changed_engine_air_filter"`
|
||||
ChangedCabinAirFilter bool `json:"changed_cabin_air_filter"`
|
||||
Notes string `json:"notes"`
|
||||
File string `json:"file"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
@@ -141,11 +142,13 @@ func (rec serviceRecord) toModel() models.ServiceRecord {
|
||||
ChangedEngineAirFilter: rec.ChangedEngineAirFilter,
|
||||
ChangedCabinAirFilter: rec.ChangedCabinAirFilter,
|
||||
Notes: rec.Notes,
|
||||
Attachment: attachmentOf(rec.File),
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
// servicePayload omits the file field — see attachmentOf.
|
||||
func servicePayload(r models.ServiceRecord) map[string]any {
|
||||
return map[string]any{
|
||||
"car": r.Car,
|
||||
@@ -158,6 +161,18 @@ func servicePayload(r models.ServiceRecord) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// --- attachments ---
|
||||
|
||||
// attachmentOf renders a PocketBase file field into the model's attachment pair.
|
||||
//
|
||||
// It has no counterpart on the write side on purpose: attachments move over
|
||||
// multipart via their own endpoint (attachments.go), never as JSON, so a
|
||||
// metadata write must not carry a file field that would blank an existing
|
||||
// upload.
|
||||
func attachmentOf(file string) models.Attachment {
|
||||
return models.Attachment{FileName: file, HasFile: file != ""}
|
||||
}
|
||||
|
||||
// --- parts ---
|
||||
|
||||
type partRecord struct {
|
||||
@@ -166,6 +181,8 @@ type partRecord struct {
|
||||
Name string `json:"name"`
|
||||
PartNumber string `json:"part_number"`
|
||||
Category string `json:"category"`
|
||||
Notes string `json:"notes"`
|
||||
File string `json:"file"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
@@ -177,17 +194,21 @@ func (rec partRecord) toModel() models.Part {
|
||||
Name: rec.Name,
|
||||
PartNumber: rec.PartNumber,
|
||||
Category: rec.Category,
|
||||
Notes: rec.Notes,
|
||||
Attachment: attachmentOf(rec.File),
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
// partPayload omits the file field — see attachmentOf.
|
||||
func partPayload(p models.Part) map[string]any {
|
||||
return map[string]any{
|
||||
"car": p.Car,
|
||||
"name": p.Name,
|
||||
"part_number": p.PartNumber,
|
||||
"category": p.Category,
|
||||
"notes": p.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +245,7 @@ type fuelRecord struct {
|
||||
MissedFill bool `json:"missed_fill"`
|
||||
Station string `json:"station"`
|
||||
Notes string `json:"notes"`
|
||||
File string `json:"file"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
@@ -240,11 +262,13 @@ func (rec fuelRecord) toModel() models.FuelEntry {
|
||||
MissedFill: rec.MissedFill,
|
||||
Station: rec.Station,
|
||||
Notes: rec.Notes,
|
||||
Attachment: attachmentOf(rec.File),
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
// fuelPayload omits the file field — see attachmentOf.
|
||||
func fuelPayload(f models.FuelEntry) map[string]any {
|
||||
return map[string]any{
|
||||
"car": f.Car,
|
||||
@@ -277,6 +301,7 @@ type maintenanceRecord struct {
|
||||
InvoiceNumber string `json:"invoice_number"`
|
||||
WarrantyUntil string `json:"warranty_until"`
|
||||
Notes string `json:"notes"`
|
||||
File string `json:"file"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
@@ -298,11 +323,13 @@ func (rec maintenanceRecord) toModel() models.MaintenanceEntry {
|
||||
InvoiceNumber: rec.InvoiceNumber,
|
||||
WarrantyUntil: parsePBDatePtr(rec.WarrantyUntil),
|
||||
Notes: rec.Notes,
|
||||
Attachment: attachmentOf(rec.File),
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
// maintenancePayload omits the file field — see attachmentOf.
|
||||
func maintenancePayload(m models.MaintenanceEntry) map[string]any {
|
||||
return map[string]any{
|
||||
"car": m.Car,
|
||||
@@ -352,15 +379,13 @@ func (rec documentRecord) toModel() models.CarDocument {
|
||||
ExpiryDate: parsePBDatePtr(rec.ExpiryDate),
|
||||
Cost: rec.Cost,
|
||||
Notes: rec.Notes,
|
||||
FileName: rec.File,
|
||||
HasFile: rec.File != "",
|
||||
Attachment: attachmentOf(rec.File),
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
// documentPayload omits the file field: attachments move over multipart, never
|
||||
// as JSON, so a metadata write must not blank an existing upload.
|
||||
// documentPayload omits the file field — see attachmentOf.
|
||||
func documentPayload(d models.CarDocument) map[string]any {
|
||||
return map[string]any{
|
||||
"car": d.Car,
|
||||
|
||||
@@ -72,9 +72,12 @@
|
||||
// GET /api/car-documents POST /api/car-documents
|
||||
// GET /api/car-documents/{id} PATCH /api/car-documents/{id}
|
||||
// DELETE /api/car-documents/{id}
|
||||
// POST /api/car-documents/{id}/file
|
||||
// GET /api/car-documents/{id}/file
|
||||
// DELETE /api/car-documents/{id}/file
|
||||
//
|
||||
// # attachments — one optional file per record, same three verbs everywhere.
|
||||
// # {records} is car-documents | service-records | maintenance | fuel-entries | parts
|
||||
// POST /api/{records}/{id}/file
|
||||
// GET /api/{records}/{id}/file
|
||||
// DELETE /api/{records}/{id}/file
|
||||
//
|
||||
// # reminders (stored + auto-derived from documents and service records)
|
||||
// GET /api/cars/{id}/reminders
|
||||
@@ -283,9 +286,6 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/car-documents/{id}", s.getDocument)
|
||||
mux.HandleFunc("PATCH /api/car-documents/{id}", s.updateDocument)
|
||||
mux.HandleFunc("DELETE /api/car-documents/{id}", s.deleteDocument)
|
||||
mux.HandleFunc("POST /api/car-documents/{id}/file", s.handleUploadDocumentFile)
|
||||
mux.HandleFunc("GET /api/car-documents/{id}/file", s.handleGetDocumentFile)
|
||||
mux.HandleFunc("DELETE /api/car-documents/{id}/file", s.handleDeleteDocumentFile)
|
||||
|
||||
// Reminders.
|
||||
mux.HandleFunc("GET /api/reminders", s.listReminders)
|
||||
@@ -295,6 +295,15 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("DELETE /api/reminders/{id}", s.deleteReminder)
|
||||
mux.HandleFunc("POST /api/reminders/{id}/complete", s.handleCompleteReminder)
|
||||
|
||||
// Attachments — one optional file per record, on identical terms for every
|
||||
// collection that takes one (see attachments.go). The GET handler passed
|
||||
// alongside is what renders the record after an upload.
|
||||
s.attachmentRoutes(mux, "/api/car-documents", colDocuments, s.getDocument)
|
||||
s.attachmentRoutes(mux, "/api/service-records", colServices, s.getServiceRecord)
|
||||
s.attachmentRoutes(mux, "/api/maintenance", colMaintenance, s.getMaintenance)
|
||||
s.attachmentRoutes(mux, "/api/fuel-entries", colFuel, s.getFuelEntry)
|
||||
s.attachmentRoutes(mux, "/api/parts", colParts, s.getPart)
|
||||
|
||||
return s.withMiddleware(mux)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,18 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Attachment is the single optional file a record carries — a scan, a receipt, a
|
||||
// workshop invoice, a photo of a part's box. Embedded by every type that can
|
||||
// hold one.
|
||||
//
|
||||
// FileName is the name PocketBase stored it under. The bytes are not in here:
|
||||
// they are served from GET /api/{records}/{id}/file, which re-checks access on
|
||||
// every request, so an attachment is never a public URL.
|
||||
type Attachment struct {
|
||||
FileName string `json:"fileName,omitempty"`
|
||||
HasFile bool `json:"hasFile"`
|
||||
}
|
||||
|
||||
// Car corresponds to one worksheet in the original spreadsheet.
|
||||
type Car struct {
|
||||
ID string `json:"id"`
|
||||
@@ -64,6 +76,9 @@ type ServiceRecord struct {
|
||||
|
||||
Notes string `json:"notes,omitempty"`
|
||||
|
||||
// The workshop receipt or stamped service-book page for this visit.
|
||||
Attachment
|
||||
|
||||
// Derived (not stored): filled in by the API on read.
|
||||
NextServiceDate *time.Time `json:"nextServiceDate,omitempty"` // Excel col C
|
||||
NextServiceKm *int `json:"nextServiceKm,omitempty"` // Excel col D
|
||||
@@ -79,6 +94,10 @@ type Part struct {
|
||||
Name string `json:"name"` // e.g. "Oil Filter"
|
||||
PartNumber string `json:"partNumber"` // e.g. "04152-YZZA7"
|
||||
Category string `json:"category"` // optional: oil|filter|wiper|other
|
||||
Notes string `json:"notes,omitempty"`
|
||||
|
||||
// A photo of the box, or the spec sheet for the part.
|
||||
Attachment
|
||||
|
||||
Created string `json:"created,omitempty"`
|
||||
Updated string `json:"updated,omitempty"`
|
||||
@@ -112,6 +131,9 @@ type FuelEntry struct {
|
||||
Station string `json:"station,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
|
||||
// The pump receipt.
|
||||
Attachment
|
||||
|
||||
// Derived (not stored): filled in by the API on read.
|
||||
PricePerLiter *float64 `json:"pricePerLiter,omitempty"`
|
||||
DistanceKm *int `json:"distanceKm,omitempty"` // since the previous full tank
|
||||
@@ -169,6 +191,9 @@ type MaintenanceEntry struct {
|
||||
WarrantyUntil *time.Time `json:"warrantyUntil,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
|
||||
// The workshop's invoice.
|
||||
Attachment
|
||||
|
||||
// Derived (not stored): filled in by the API on read.
|
||||
TotalCost float64 `json:"totalCost"`
|
||||
WarrantyActive *bool `json:"warrantyActive,omitempty"`
|
||||
@@ -196,10 +221,8 @@ type CarDocument struct {
|
||||
Cost float64 `json:"cost"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
|
||||
// FileName is the stored attachment (scan/PDF), served via
|
||||
// GET /api/car-documents/{id}/file. Empty when nothing is attached.
|
||||
FileName string `json:"fileName,omitempty"`
|
||||
HasFile bool `json:"hasFile"`
|
||||
// The scan or photo of the paperwork itself.
|
||||
Attachment
|
||||
|
||||
// Derived (not stored): filled in by the API on read.
|
||||
Expiry ExpiryAssessment `json:"expiry"`
|
||||
|
||||
@@ -211,6 +211,19 @@ async function reconcileFields(token, name, defs, format, idByName) {
|
||||
console.log(`✓ ${name} — ${changes.join(", ")}`);
|
||||
}
|
||||
|
||||
// The single optional attachment a record can carry — a scan, a receipt, a
|
||||
// workshop invoice, a photo of a part. The 10MB cap matches maxAttachmentUpload
|
||||
// in the API server, and the file is reached only via the API's own file
|
||||
// endpoint, never as a public URL.
|
||||
const attachment = () =>
|
||||
F.file("file", 10485760, [
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
]);
|
||||
|
||||
// Desired schema. Edit here to evolve collections; re-run the script to apply.
|
||||
const DESIRED = {
|
||||
cars: [
|
||||
@@ -246,12 +259,15 @@ const DESIRED = {
|
||||
F.bool("changed_engine_air_filter"),
|
||||
F.bool("changed_cabin_air_filter"),
|
||||
F.text("notes"),
|
||||
attachment(), // the workshop receipt / stamped service-book page
|
||||
],
|
||||
parts: [
|
||||
F.relation("car", "cars", true),
|
||||
F.text("name", true),
|
||||
F.text("part_number"),
|
||||
F.text("category"),
|
||||
F.text("notes"),
|
||||
attachment(), // a photo of the box, or the part's spec sheet
|
||||
],
|
||||
// Fuel refills. Consumption is NOT stored — the API derives it from the whole
|
||||
// history on read (models.ComputeFuelDerived), so correcting an old fill fixes
|
||||
@@ -269,6 +285,7 @@ const DESIRED = {
|
||||
F.bool("missed_fill"),
|
||||
F.text("station"),
|
||||
F.text("notes"),
|
||||
attachment(), // the pump receipt
|
||||
],
|
||||
// Workshop visits and repairs. Deliberately separate from service_records:
|
||||
// that collection is the routine interval schedule (and drives next-service
|
||||
@@ -288,6 +305,7 @@ const DESIRED = {
|
||||
F.text("invoice_number"),
|
||||
F.date("warranty_until"),
|
||||
F.text("notes"),
|
||||
attachment(), // the workshop's invoice
|
||||
],
|
||||
// Insurance, pollution certificates, registration papers … The expiry date is
|
||||
// the point of the record: it drives the renewal status badges and the
|
||||
@@ -302,15 +320,7 @@ const DESIRED = {
|
||||
F.date("expiry_date"),
|
||||
F.number("cost"),
|
||||
F.text("notes"),
|
||||
// The scan/PDF. 10MB cap, matching maxDocumentUpload in the API server.
|
||||
// Reached only via the API's own file endpoint, never as a public URL.
|
||||
F.file("file", 10485760, [
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
]),
|
||||
attachment(), // the scan/PDF of the paperwork itself
|
||||
],
|
||||
// User-set reminders. The API additionally synthesises read-only ones from
|
||||
// document expiry dates and the next service due — those are derived on read
|
||||
|
||||
+28
-8
@@ -88,6 +88,25 @@ async function requestBlob(path) {
|
||||
return { blob: await res.blob(), filename };
|
||||
}
|
||||
|
||||
// Attachments. Every record that can carry a file — documents, service records,
|
||||
// workshop visits, refills, catalog parts — exposes the same three endpoints
|
||||
// under its own path, so they are built from one place rather than spelled out
|
||||
// five times.
|
||||
//
|
||||
// The file is proxied by the API (PocketBase's files aren't public), so it needs
|
||||
// the auth header — hence a multipart POST and a Blob fetch rather than a plain
|
||||
// <a href>. Uploading addresses a record that must already exist; see
|
||||
// applyAttachment in lib/attachment.js for the order the forms use.
|
||||
const attachment = (path) => ({
|
||||
upload: (id, file) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return requestForm(`${path}/${id}/file`, { method: "POST", body: form });
|
||||
},
|
||||
download: (id) => requestBlob(`${path}/${id}/file`),
|
||||
remove: (id) => request(`${path}/${id}/file`, { method: "DELETE" }),
|
||||
});
|
||||
|
||||
export const api = {
|
||||
// Auth
|
||||
login: (email, password) =>
|
||||
@@ -145,15 +164,16 @@ export const api = {
|
||||
updateDocument: (id, body) =>
|
||||
request(`/car-documents/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteDocument: (id) => request(`/car-documents/${id}`, { method: "DELETE" }),
|
||||
// The attachment is proxied by the API (PocketBase files aren't public), so it
|
||||
// needs the auth header — hence a Blob fetch rather than a plain link.
|
||||
uploadDocumentFile: (id, file) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return requestForm(`/car-documents/${id}/file`, { method: "POST", body: form });
|
||||
|
||||
// Attachments, one per record. Keyed by the same names CarDetail uses for its
|
||||
// tabs so a table row can reach for the right one generically.
|
||||
files: {
|
||||
documents: attachment("/car-documents"),
|
||||
services: attachment("/service-records"),
|
||||
maintenance: attachment("/maintenance"),
|
||||
fuel: attachment("/fuel-entries"),
|
||||
parts: attachment("/parts"),
|
||||
},
|
||||
getDocumentFileBlob: (id) => requestBlob(`/car-documents/${id}/file`),
|
||||
deleteDocumentFile: (id) => request(`/car-documents/${id}/file`, { method: "DELETE" }),
|
||||
|
||||
// Reminders. The list mixes stored reminders with read-only ones derived from
|
||||
// documents and service records (flagged `auto`; their ids start with "auto:").
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
// The attachment picker shared by every form that can carry a file.
|
||||
//
|
||||
// It only collects intent — a picked file, or a request to detach the existing
|
||||
// one. Actually moving the bytes is the parent's job (applyAttachment), because
|
||||
// the endpoint addresses a record that must already exist.
|
||||
defineProps({
|
||||
// The saved record, when editing; null while creating. Read for the name of
|
||||
// whatever is already attached.
|
||||
record: { type: Object, default: null },
|
||||
file: { type: Object, default: null },
|
||||
remove: { type: Boolean, default: false },
|
||||
legend: { type: String, default: "Attachment" },
|
||||
hint: { type: String, default: "PDF or image, up to 10MB." },
|
||||
});
|
||||
const emit = defineEmits(["update:file", "update:remove"]);
|
||||
|
||||
function onFilePick(e) {
|
||||
const picked = e.target.files?.[0] || null;
|
||||
emit("update:file", picked);
|
||||
// Picking a replacement supersedes a pending detach.
|
||||
if (picked) emit("update:remove", false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset class="rounded-control border border-subtle p-3">
|
||||
<legend class="eyebrow px-1">{{ legend }}</legend>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp,.heic"
|
||||
class="w-full text-sm text-body"
|
||||
@change="onFilePick"
|
||||
/>
|
||||
<p v-if="record?.hasFile && !file && !remove" class="mt-2 flex items-center gap-2 text-xs text-muted">
|
||||
<span>Attached: <span class="data text-strong">{{ record.fileName }}</span></span>
|
||||
<button type="button" class="font-medium text-danger hover:underline" @click="emit('update:remove', true)">
|
||||
Remove
|
||||
</button>
|
||||
</p>
|
||||
<p v-else-if="remove" class="mt-2 flex items-center gap-2 text-xs text-muted">
|
||||
<span>Attachment will be removed on save.</span>
|
||||
<button type="button" class="font-medium text-brandtext hover:underline" @click="emit('update:remove', false)">
|
||||
Undo
|
||||
</button>
|
||||
</p>
|
||||
<p class="mt-1.5 text-xs text-muted">{{ hint }}</p>
|
||||
</fieldset>
|
||||
</template>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { api } from "../api";
|
||||
import { applyAttachment } from "../lib/attachment.js";
|
||||
import AttachmentField from "./AttachmentField.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -34,10 +36,7 @@ const form = ref({
|
||||
notes: props.doc?.notes ?? "",
|
||||
});
|
||||
|
||||
// The picked file is uploaded after the metadata save, since the attachment
|
||||
// endpoint addresses a document that must already exist.
|
||||
const file = ref(null);
|
||||
// Tracks a request to detach the existing attachment without picking a new one.
|
||||
const removeFile = ref(false);
|
||||
|
||||
function toDateInput(value) {
|
||||
@@ -45,11 +44,6 @@ function toDateInput(value) {
|
||||
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function onFilePick(e) {
|
||||
file.value = e.target.files?.[0] || null;
|
||||
if (file.value) removeFile.value = false;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
@@ -57,17 +51,10 @@ async function submit() {
|
||||
const saved = isEdit
|
||||
? await api.updateDocument(props.doc.id, payload())
|
||||
: await api.createDocument(payload());
|
||||
|
||||
// Attachment changes are separate calls; a failure here must not read as a
|
||||
// failed save, because the metadata is already committed.
|
||||
let final = saved;
|
||||
if (file.value) {
|
||||
final = await api.uploadDocumentFile(saved.id, file.value);
|
||||
} else if (removeFile.value && isEdit) {
|
||||
await api.deleteDocumentFile(saved.id);
|
||||
final = { ...saved, fileName: "", hasFile: false };
|
||||
}
|
||||
emit("saved", final);
|
||||
emit("saved", await applyAttachment(api.files.documents, saved, {
|
||||
file: file.value,
|
||||
remove: removeFile.value,
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
@@ -136,19 +123,7 @@ function payload() {
|
||||
<input v-model="form.cost" type="number" step="0.01" min="0" class="dh-input data" />
|
||||
</div>
|
||||
|
||||
<fieldset class="rounded-control border border-subtle p-3">
|
||||
<legend class="eyebrow px-1">Scan or photo</legend>
|
||||
<input type="file" accept=".pdf,.jpg,.jpeg,.png,.webp,.heic" class="w-full text-sm text-body" @change="onFilePick" />
|
||||
<p v-if="isEdit && doc.hasFile && !file && !removeFile" class="mt-2 flex items-center gap-2 text-xs text-muted">
|
||||
<span>Attached: <span class="data text-strong">{{ doc.fileName }}</span></span>
|
||||
<button type="button" class="font-medium text-danger hover:underline" @click="removeFile = true">Remove</button>
|
||||
</p>
|
||||
<p v-else-if="removeFile" class="mt-2 flex items-center gap-2 text-xs text-muted">
|
||||
<span>Attachment will be removed on save.</span>
|
||||
<button type="button" class="font-medium text-brandtext hover:underline" @click="removeFile = false">Undo</button>
|
||||
</p>
|
||||
<p class="mt-1.5 text-xs text-muted">PDF or image, up to 10MB.</p>
|
||||
</fieldset>
|
||||
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="doc" legend="Scan or photo" />
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Notes</label>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { api } from "../api";
|
||||
import { applyAttachment } from "../lib/attachment.js";
|
||||
import AttachmentField from "./AttachmentField.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -26,6 +28,9 @@ const form = ref({
|
||||
notes: props.entry?.notes ?? "",
|
||||
});
|
||||
|
||||
const file = ref(null);
|
||||
const removeFile = ref(false);
|
||||
|
||||
function toDateInput(value) {
|
||||
const d = new Date(value);
|
||||
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
|
||||
@@ -43,7 +48,10 @@ async function submit() {
|
||||
error.value = "";
|
||||
try {
|
||||
const saved = await (isEdit ? api.updateFuel(props.entry.id, payload()) : api.createFuel(payload()));
|
||||
emit("saved", saved);
|
||||
emit("saved", await applyAttachment(api.files.fuel, saved, {
|
||||
file: file.value,
|
||||
remove: removeFile.value,
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
@@ -121,6 +129,8 @@ function payload() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="entry" legend="Receipt" />
|
||||
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
|
||||
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ref, computed } from "vue";
|
||||
import { api } from "../api";
|
||||
import { formatMoney } from "../lib/format.js";
|
||||
import { applyAttachment } from "../lib/attachment.js";
|
||||
import AttachmentField from "./AttachmentField.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -47,6 +49,9 @@ const form = ref({
|
||||
notes: props.entry?.notes ?? "",
|
||||
});
|
||||
|
||||
const file = ref(null);
|
||||
const removeFile = ref(false);
|
||||
|
||||
function toDateInput(value) {
|
||||
const d = new Date(value);
|
||||
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
|
||||
@@ -64,7 +69,10 @@ async function submit() {
|
||||
const saved = await (isEdit
|
||||
? api.updateMaintenance(props.entry.id, payload())
|
||||
: api.createMaintenance(payload()));
|
||||
emit("saved", saved);
|
||||
emit("saved", await applyAttachment(api.files.maintenance, saved, {
|
||||
file: file.value,
|
||||
remove: removeFile.value,
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
@@ -169,6 +177,8 @@ function payload() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="entry" legend="Invoice" />
|
||||
|
||||
<div>
|
||||
<label class="dh-label">Notes</label>
|
||||
<input v-model="form.notes" class="dh-input" />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { api } from "../api";
|
||||
import { applyAttachment } from "../lib/attachment.js";
|
||||
import AttachmentField from "./AttachmentField.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -16,8 +18,12 @@ const form = ref({
|
||||
name: props.part?.name ?? "",
|
||||
partNumber: props.part?.partNumber ?? "",
|
||||
category: props.part?.category ?? "",
|
||||
notes: props.part?.notes ?? "",
|
||||
});
|
||||
|
||||
const file = ref(null);
|
||||
const removeFile = ref(false);
|
||||
|
||||
async function submit() {
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
@@ -27,11 +33,15 @@ async function submit() {
|
||||
name: form.value.name.trim(),
|
||||
partNumber: form.value.partNumber.trim(),
|
||||
category: form.value.category.trim(),
|
||||
notes: form.value.notes.trim(),
|
||||
};
|
||||
const saved = isEdit
|
||||
? await api.updatePart(props.part.id, payload)
|
||||
: await api.createPart(payload);
|
||||
emit("saved", saved);
|
||||
emit("saved", await applyAttachment(api.files.parts, saved, {
|
||||
file: file.value,
|
||||
remove: removeFile.value,
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
@@ -52,6 +62,11 @@ async function submit() {
|
||||
<label class="dh-label">Part number</label>
|
||||
<input v-model="form.partNumber" placeholder="04152-YZZA7" class="dh-input data" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Notes</label>
|
||||
<input v-model="form.notes" placeholder="Fits 2015–2020 · buy in pairs" class="dh-input" />
|
||||
</div>
|
||||
<AttachmentField v-model:file="file" v-model:remove="removeFile" :record="part" legend="Photo or spec sheet" />
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">Cancel</button>
|
||||
<button type="submit" :disabled="saving" class="dh-btn dh-btn-primary">
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ref } from "vue";
|
||||
import { api } from "../api";
|
||||
import { formatKm } from "../lib/format.js";
|
||||
import { applyAttachment } from "../lib/attachment.js";
|
||||
import AttachmentField from "./AttachmentField.vue";
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -23,6 +25,9 @@ const form = ref({
|
||||
notes: props.service?.notes ?? "",
|
||||
});
|
||||
|
||||
const file = ref(null);
|
||||
const removeFile = ref(false);
|
||||
|
||||
function toDateInput(value) {
|
||||
const d = new Date(value);
|
||||
return isNaN(d) ? "" : d.toISOString().slice(0, 10);
|
||||
@@ -44,7 +49,10 @@ async function submit() {
|
||||
const saved = isEdit
|
||||
? await api.updateService(props.service.id, payload)
|
||||
: await api.createService(payload);
|
||||
emit("saved", saved);
|
||||
emit("saved", await applyAttachment(api.files.services, saved, {
|
||||
file: file.value,
|
||||
remove: removeFile.value,
|
||||
}));
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
@@ -69,10 +77,16 @@ async function submit() {
|
||||
</div>
|
||||
<fieldset class="rounded-control border border-subtle p-3">
|
||||
<legend class="eyebrow px-1">Changed parts</legend>
|
||||
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedOil" class="accent-[var(--accent)]" /> Oil & oil filter</label>
|
||||
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedOil" class="accent-[var(--accent)]" /> Oil & Oil filter</label>
|
||||
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedEngineAirFilter" class="accent-[var(--accent)]" /> Engine air filter</label>
|
||||
<label class="flex items-center gap-2 py-1 text-sm text-body"><input type="checkbox" v-model="form.changedCabinAirFilter" class="accent-[var(--accent)]" /> Cabin air filter</label>
|
||||
</fieldset>
|
||||
<AttachmentField
|
||||
v-model:file="file"
|
||||
v-model:remove="removeFile"
|
||||
:record="service"
|
||||
legend="Receipt or service-book page"
|
||||
/>
|
||||
<div>
|
||||
<label class="dh-label">Notes</label>
|
||||
<input v-model="form.notes" class="dh-input" />
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Applies a form's pending attachment change to the record it has just saved,
|
||||
// and returns the record the form should hand back to its parent.
|
||||
//
|
||||
// This necessarily runs after the metadata write: the file endpoints address a
|
||||
// record that must already exist. The order means a create-with-file is two
|
||||
// calls, and the second one failing leaves a saved record with no attachment —
|
||||
// which is why the caller reports it as an attachment error rather than a failed
|
||||
// save, because the metadata is already committed.
|
||||
export async function applyAttachment(files, saved, { file, remove }) {
|
||||
if (file) return files.upload(saved.id, file);
|
||||
if (remove) {
|
||||
await files.remove(saved.id);
|
||||
return { ...saved, fileName: "", hasFile: false };
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
@@ -83,8 +83,8 @@ const TABS = [
|
||||
{ key: "maintenance", label: "Maintenance log" },
|
||||
{ key: "fuel", label: "Fuel" },
|
||||
{ key: "documents", label: "Documents" },
|
||||
{ key: "reminders", label: "Reminders" },
|
||||
{ key: "parts", label: "Parts catalog" },
|
||||
{ key: "reminders", label: "Reminders" },
|
||||
];
|
||||
|
||||
async function load() {
|
||||
@@ -258,15 +258,16 @@ async function deleteDocument(id) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
// The attachment needs the auth header, so it is fetched as a Blob rather than
|
||||
// linked to directly.
|
||||
async function downloadDocument(doc) {
|
||||
// --- attachments ---
|
||||
// The file needs the auth header, so it is fetched as a Blob rather than linked
|
||||
// to directly. `kind` names one of api.files — the same keys as the tabs.
|
||||
async function downloadAttachment(kind, record) {
|
||||
try {
|
||||
const { blob, filename } = await api.getDocumentFileBlob(doc.id);
|
||||
const { blob, filename } = await api.files[kind].download(record.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename || doc.fileName || "document";
|
||||
a.download = filename || record.fileName || "attachment";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
@@ -503,10 +504,11 @@ onMounted(load);
|
||||
<th>Km</th>
|
||||
<th>Next date</th>
|
||||
<th>Next km</th>
|
||||
<th class="!text-center">Oil & filter</th>
|
||||
<th class="!text-center">Engine air</th>
|
||||
<th class="!text-center">Cabin air</th>
|
||||
<th class="!text-center">Oil & Oil filter</th>
|
||||
<th class="!text-center">Engine air filter</th>
|
||||
<th class="!text-center">Cabin air filter</th>
|
||||
<th>Notes</th>
|
||||
<th>File</th>
|
||||
<th v-if="canWrite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -520,6 +522,12 @@ onMounted(load);
|
||||
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedEngineAirFilter ? 'text-success' : 'text-muted'">{{ yn(s.changedEngineAirFilter) }}</td>
|
||||
<td class="px-4 py-3 text-center text-xs font-semibold" :class="s.changedCabinAirFilter ? 'text-success' : 'text-muted'">{{ yn(s.changedCabinAirFilter) }}</td>
|
||||
<td class="px-4 py-3 text-body">{{ s.notes || '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<button v-if="s.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('services', s)">
|
||||
Download
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
</td>
|
||||
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditService(s)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteService(s.id)">Delete</button>
|
||||
@@ -558,6 +566,7 @@ onMounted(load);
|
||||
<th>Workshop</th>
|
||||
<th>Status</th>
|
||||
<th class="!text-right">Cost</th>
|
||||
<th>File</th>
|
||||
<th v-if="canWrite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -585,6 +594,12 @@ onMounted(load);
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right data text-body">
|
||||
{{ m.totalCost ? formatMoney(m.totalCost) : '—' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<button v-if="m.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('maintenance', m)">
|
||||
Download
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
</td>
|
||||
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditMaintenance(m)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteMaintenance(m.id)">Delete</button>
|
||||
@@ -667,6 +682,7 @@ onMounted(load);
|
||||
<th class="!text-right">Distance</th>
|
||||
<th class="!text-right">Consumption</th>
|
||||
<th>Station</th>
|
||||
<th>File</th>
|
||||
<th v-if="canWrite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -689,6 +705,12 @@ onMounted(load);
|
||||
{{ f.station || '—' }}
|
||||
<div v-if="f.notes" class="text-xs text-muted">{{ f.notes }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<button v-if="f.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('fuel', f)">
|
||||
Download
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
</td>
|
||||
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditFuel(f)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deleteFuel(f.id)">Delete</button>
|
||||
@@ -744,7 +766,7 @@ onMounted(load);
|
||||
<span :class="expiryStatus(d).classes">{{ expiryStatus(d).label }}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<button v-if="d.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadDocument(d)">
|
||||
<button v-if="d.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('documents', d)">
|
||||
Download
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
@@ -827,12 +849,14 @@ onMounted(load);
|
||||
No parts yet.
|
||||
</div>
|
||||
|
||||
<div v-else class="dh-card overflow-hidden p-0">
|
||||
<div v-else class="dh-card overflow-x-auto p-0">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-sunken text-left">
|
||||
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
|
||||
<th>Part</th>
|
||||
<th>Part number</th>
|
||||
<th>Notes</th>
|
||||
<th>File</th>
|
||||
<th v-if="canWrite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -840,6 +864,13 @@ onMounted(load);
|
||||
<tr v-for="p in parts" :key="p.id" class="transition-colors hover:bg-sunken">
|
||||
<td class="px-4 py-3 font-medium text-strong">{{ p.name }}</td>
|
||||
<td class="px-4 py-3 data text-body">{{ p.partNumber || '—' }}</td>
|
||||
<td class="px-4 py-3 text-body">{{ p.notes || '—' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<button v-if="p.hasFile" class="text-xs font-medium text-brandtext hover:underline" @click="downloadAttachment('parts', p)">
|
||||
Download
|
||||
</button>
|
||||
<span v-else class="text-xs text-muted">—</span>
|
||||
</td>
|
||||
<td v-if="canWrite" class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<button class="text-xs font-medium text-brandtext hover:underline" @click="openEditPart(p)">Edit</button>
|
||||
<button class="ml-3 text-xs font-medium text-danger hover:underline" @click="deletePart(p.id)">Delete</button>
|
||||
|
||||
Reference in New Issue
Block a user