Major overhaul to the Notes/Issues. Minor tweaks to the UI. Added Profile photos
This commit is contained in:
@@ -162,6 +162,7 @@ class DeviceUserInfo(BaseModel):
|
|||||||
display_name: str = ""
|
display_name: str = ""
|
||||||
email: str = ""
|
email: str = ""
|
||||||
role: str = ""
|
role: str = ""
|
||||||
|
photo_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
class DeviceUsersResponse(BaseModel):
|
class DeviceUsersResponse(BaseModel):
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ def get_device_users(device_doc_id: str) -> list[dict]:
|
|||||||
user_id = ""
|
user_id = ""
|
||||||
display_name = ""
|
display_name = ""
|
||||||
email = ""
|
email = ""
|
||||||
|
photo_url = ""
|
||||||
|
|
||||||
if isinstance(user_ref, DocumentReference):
|
if isinstance(user_ref, DocumentReference):
|
||||||
try:
|
try:
|
||||||
@@ -200,6 +201,7 @@ def get_device_users(device_doc_id: str) -> list[dict]:
|
|||||||
user_id = user_doc.id
|
user_id = user_doc.id
|
||||||
display_name = user_data.get("display_name", "")
|
display_name = user_data.get("display_name", "")
|
||||||
email = user_data.get("email", "")
|
email = user_data.get("email", "")
|
||||||
|
photo_url = user_data.get("photo_url", "")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[devices] Error resolving user reference: {e}")
|
print(f"[devices] Error resolving user reference: {e}")
|
||||||
continue
|
continue
|
||||||
@@ -212,6 +214,7 @@ def get_device_users(device_doc_id: str) -> list[dict]:
|
|||||||
user_id = ref_doc.id
|
user_id = ref_doc.id
|
||||||
display_name = user_data.get("display_name", "")
|
display_name = user_data.get("display_name", "")
|
||||||
email = user_data.get("email", "")
|
email = user_data.get("email", "")
|
||||||
|
photo_url = user_data.get("photo_url", "")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[devices] Error resolving user path: {e}")
|
print(f"[devices] Error resolving user path: {e}")
|
||||||
continue
|
continue
|
||||||
@@ -221,6 +224,7 @@ def get_device_users(device_doc_id: str) -> list[dict]:
|
|||||||
"display_name": display_name,
|
"display_name": display_name,
|
||||||
"email": email,
|
"email": email,
|
||||||
"role": role,
|
"role": role,
|
||||||
|
"photo_url": photo_url,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# Fallback to user_list field
|
# Fallback to user_list field
|
||||||
@@ -246,6 +250,7 @@ def get_device_users(device_doc_id: str) -> list[dict]:
|
|||||||
"display_name": user_data.get("display_name", ""),
|
"display_name": user_data.get("display_name", ""),
|
||||||
"email": user_data.get("email", ""),
|
"email": user_data.get("email", ""),
|
||||||
"role": "",
|
"role": "",
|
||||||
|
"photo_url": user_data.get("photo_url", ""),
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[devices] Error resolving user_list entry: {e}")
|
print(f"[devices] Error resolving user_list entry: {e}")
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ class NoteCreate(BaseModel):
|
|||||||
"""Create a new equipment note/log entry."""
|
"""Create a new equipment note/log entry."""
|
||||||
title: str
|
title: str
|
||||||
content: str
|
content: str
|
||||||
category: str = "general" # general, maintenance, installation, issue, other
|
category: str = "general" # general, maintenance, installation, issue, action_item, other
|
||||||
device_id: Optional[str] = None # Firestore doc ID of linked device
|
device_id: Optional[str] = None # Firestore doc ID of linked device
|
||||||
user_id: Optional[str] = None # Firestore doc ID of linked user
|
user_id: Optional[str] = None # Firestore doc ID of linked user
|
||||||
|
status: str = "" # "", "completed"
|
||||||
|
|
||||||
|
|
||||||
class NoteUpdate(BaseModel):
|
class NoteUpdate(BaseModel):
|
||||||
@@ -20,6 +21,7 @@ class NoteUpdate(BaseModel):
|
|||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
device_id: Optional[str] = None
|
device_id: Optional[str] = None
|
||||||
user_id: Optional[str] = None
|
user_id: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class NoteInDB(BaseModel):
|
class NoteInDB(BaseModel):
|
||||||
@@ -35,6 +37,7 @@ class NoteInDB(BaseModel):
|
|||||||
created_by: str = ""
|
created_by: str = ""
|
||||||
created_at: str = ""
|
created_at: str = ""
|
||||||
updated_at: str = ""
|
updated_at: str = ""
|
||||||
|
status: str = ""
|
||||||
|
|
||||||
|
|
||||||
class NoteListResponse(BaseModel):
|
class NoteListResponse(BaseModel):
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ async def update_note(
|
|||||||
return service.update_note(note_id, body)
|
return service.update_note(note_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{note_id}/status", response_model=NoteInDB)
|
||||||
|
async def toggle_note_status(
|
||||||
|
note_id: str,
|
||||||
|
body: NoteUpdate,
|
||||||
|
_user: TokenPayload = Depends(require_permission("equipment", "edit")),
|
||||||
|
):
|
||||||
|
return service.update_note(note_id, body)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{note_id}", status_code=204)
|
@router.delete("/{note_id}", status_code=204)
|
||||||
async def delete_note(
|
async def delete_note(
|
||||||
note_id: str,
|
note_id: str,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from equipment.models import NoteCreate, NoteUpdate, NoteInDB
|
|||||||
|
|
||||||
COLLECTION = "equipment_notes"
|
COLLECTION = "equipment_notes"
|
||||||
|
|
||||||
VALID_CATEGORIES = {"general", "maintenance", "installation", "issue", "other"}
|
VALID_CATEGORIES = {"general", "maintenance", "installation", "issue", "action_item", "other"}
|
||||||
|
|
||||||
|
|
||||||
def _convert_firestore_value(val):
|
def _convert_firestore_value(val):
|
||||||
@@ -81,6 +81,9 @@ def list_notes(
|
|||||||
if user_id:
|
if user_id:
|
||||||
query = query.where("user_id", "==", user_id)
|
query = query.where("user_id", "==", user_id)
|
||||||
|
|
||||||
|
# Only use order_by when no field filters are applied (avoids composite index requirement)
|
||||||
|
has_field_filters = bool(category or device_id or user_id)
|
||||||
|
if not has_field_filters:
|
||||||
query = query.order_by("created_at", direction="DESCENDING")
|
query = query.order_by("created_at", direction="DESCENDING")
|
||||||
|
|
||||||
docs = query.stream()
|
docs = query.stream()
|
||||||
@@ -98,6 +101,10 @@ def list_notes(
|
|||||||
|
|
||||||
results.append(note)
|
results.append(note)
|
||||||
|
|
||||||
|
# Sort client-side when we couldn't use order_by
|
||||||
|
if has_field_filters:
|
||||||
|
results.sort(key=lambda n: n.created_at or "", reverse=True)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
0
backend/helpdesk/__init__.py
Normal file
0
backend/helpdesk/__init__.py
Normal file
24
backend/helpdesk/models.py
Normal file
24
backend/helpdesk/models.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class HelpdeskMessage(BaseModel):
|
||||||
|
"""Helpdesk message as stored in Firestore."""
|
||||||
|
id: str
|
||||||
|
sender_id: str = ""
|
||||||
|
sender_name: str = ""
|
||||||
|
type: str = "" # Problem, Suggestion, Question, Other
|
||||||
|
date_sent: str = ""
|
||||||
|
subject: str = ""
|
||||||
|
message: str = ""
|
||||||
|
phone: str = ""
|
||||||
|
device_id: str = ""
|
||||||
|
device_name: str = ""
|
||||||
|
acknowledged: bool = False
|
||||||
|
acknowledged_by: str = ""
|
||||||
|
acknowledged_at: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class HelpdeskListResponse(BaseModel):
|
||||||
|
messages: List[HelpdeskMessage]
|
||||||
|
total: int
|
||||||
39
backend/helpdesk/router.py
Normal file
39
backend/helpdesk/router.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from typing import Optional
|
||||||
|
from auth.models import TokenPayload
|
||||||
|
from auth.dependencies import require_permission
|
||||||
|
from helpdesk.models import HelpdeskMessage, HelpdeskListResponse
|
||||||
|
from helpdesk import service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/helpdesk", tags=["helpdesk"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=HelpdeskListResponse)
|
||||||
|
async def list_messages(
|
||||||
|
user_id: Optional[str] = Query(None),
|
||||||
|
device_id: Optional[str] = Query(None),
|
||||||
|
type: Optional[str] = Query(None),
|
||||||
|
_user: TokenPayload = Depends(require_permission("equipment", "view")),
|
||||||
|
):
|
||||||
|
messages = service.list_messages(
|
||||||
|
user_id=user_id, device_id=device_id, msg_type=type,
|
||||||
|
)
|
||||||
|
return HelpdeskListResponse(messages=messages, total=len(messages))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{message_id}", response_model=HelpdeskMessage)
|
||||||
|
async def get_message(
|
||||||
|
message_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("equipment", "view")),
|
||||||
|
):
|
||||||
|
return service.get_message(message_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{message_id}/acknowledge", response_model=HelpdeskMessage)
|
||||||
|
async def toggle_acknowledged(
|
||||||
|
message_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("equipment", "edit")),
|
||||||
|
):
|
||||||
|
return service.toggle_acknowledged(
|
||||||
|
message_id, acknowledged_by=_user.name or _user.email,
|
||||||
|
)
|
||||||
169
backend/helpdesk/service.py
Normal file
169
backend/helpdesk/service.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from google.cloud.firestore_v1 import DocumentReference
|
||||||
|
|
||||||
|
from shared.firebase import get_db
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
from helpdesk.models import HelpdeskMessage
|
||||||
|
|
||||||
|
COLLECTION = "helpdesk"
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_firestore_value(val):
|
||||||
|
"""Convert Firestore-specific types to strings."""
|
||||||
|
if isinstance(val, datetime):
|
||||||
|
return val.strftime("%d %B %Y at %H:%M:%S UTC%z")
|
||||||
|
if isinstance(val, DocumentReference):
|
||||||
|
return val.path
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_sender(db, sender_ref) -> tuple[str, str]:
|
||||||
|
"""Resolve a sender DocumentReference to (sender_id, sender_name)."""
|
||||||
|
sender_id = ""
|
||||||
|
sender_name = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(sender_ref, DocumentReference):
|
||||||
|
doc = sender_ref.get()
|
||||||
|
if doc.exists:
|
||||||
|
data = doc.to_dict()
|
||||||
|
sender_id = doc.id
|
||||||
|
sender_name = data.get("display_name", "") or data.get("email", "")
|
||||||
|
elif isinstance(sender_ref, str) and sender_ref.strip():
|
||||||
|
# String path like "users/abc123"
|
||||||
|
doc = db.document(sender_ref).get()
|
||||||
|
if doc.exists:
|
||||||
|
data = doc.to_dict()
|
||||||
|
sender_id = doc.id
|
||||||
|
sender_name = data.get("display_name", "") or data.get("email", "")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[helpdesk] Error resolving sender: {e}")
|
||||||
|
|
||||||
|
return sender_id, sender_name
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_device_name(db, device_id: str) -> str:
|
||||||
|
"""Look up device_name from device_id."""
|
||||||
|
if not device_id:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
doc = db.collection("devices").document(device_id.strip()).get()
|
||||||
|
if doc.exists:
|
||||||
|
return doc.to_dict().get("device_name", "")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[helpdesk] Error resolving device name: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_to_message(db, doc) -> HelpdeskMessage:
|
||||||
|
"""Convert a Firestore document snapshot to a HelpdeskMessage."""
|
||||||
|
data = doc.to_dict()
|
||||||
|
|
||||||
|
# Resolve sender reference
|
||||||
|
sender_ref = data.get("sender")
|
||||||
|
sender_id, sender_name = _resolve_sender(db, sender_ref)
|
||||||
|
|
||||||
|
# Handle date_sent (could be Firestore Timestamp)
|
||||||
|
date_sent = data.get("date_sent", "")
|
||||||
|
if isinstance(date_sent, datetime):
|
||||||
|
date_sent = date_sent.strftime("%d %B %Y at %H:%M:%S UTC%z")
|
||||||
|
|
||||||
|
# Resolve device name if device_id present
|
||||||
|
device_id = data.get("device_id", "")
|
||||||
|
device_name = ""
|
||||||
|
if device_id:
|
||||||
|
device_name = _resolve_device_name(db, device_id)
|
||||||
|
|
||||||
|
# Handle acknowledged_at
|
||||||
|
acknowledged_at = data.get("acknowledged_at", "")
|
||||||
|
if isinstance(acknowledged_at, datetime):
|
||||||
|
acknowledged_at = acknowledged_at.strftime("%d %B %Y at %H:%M:%S UTC%z")
|
||||||
|
|
||||||
|
return HelpdeskMessage(
|
||||||
|
id=doc.id,
|
||||||
|
sender_id=sender_id,
|
||||||
|
sender_name=sender_name,
|
||||||
|
type=data.get("type", ""),
|
||||||
|
date_sent=date_sent,
|
||||||
|
subject=data.get("subject", ""),
|
||||||
|
message=data.get("message", ""),
|
||||||
|
phone=data.get("phone", ""),
|
||||||
|
device_id=device_id,
|
||||||
|
device_name=device_name,
|
||||||
|
acknowledged=data.get("acknowledged", False),
|
||||||
|
acknowledged_by=data.get("acknowledged_by", ""),
|
||||||
|
acknowledged_at=acknowledged_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_messages(
|
||||||
|
user_id: str | None = None,
|
||||||
|
device_id: str | None = None,
|
||||||
|
msg_type: str | None = None,
|
||||||
|
) -> list[HelpdeskMessage]:
|
||||||
|
"""List helpdesk messages with optional filters."""
|
||||||
|
db = get_db()
|
||||||
|
ref = db.collection(COLLECTION)
|
||||||
|
query = ref
|
||||||
|
|
||||||
|
if msg_type:
|
||||||
|
query = query.where("type", "==", msg_type)
|
||||||
|
|
||||||
|
if device_id:
|
||||||
|
query = query.where("device_id", "==", device_id)
|
||||||
|
|
||||||
|
docs = list(query.stream())
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for doc in docs:
|
||||||
|
msg = _doc_to_message(db, doc)
|
||||||
|
|
||||||
|
# Filter by sender user_id client-side (sender is a DocumentReference)
|
||||||
|
if user_id and msg.sender_id != user_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append(msg)
|
||||||
|
|
||||||
|
# Sort by date_sent descending
|
||||||
|
results.sort(key=lambda m: m.date_sent or "", reverse=True)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def get_message(message_id: str) -> HelpdeskMessage:
|
||||||
|
"""Get a single helpdesk message by ID."""
|
||||||
|
db = get_db()
|
||||||
|
doc = db.collection(COLLECTION).document(message_id).get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Helpdesk message")
|
||||||
|
return _doc_to_message(db, doc)
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_acknowledged(message_id: str, acknowledged_by: str = "") -> HelpdeskMessage:
|
||||||
|
"""Toggle the acknowledged status of a helpdesk message."""
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(COLLECTION).document(message_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Helpdesk message")
|
||||||
|
|
||||||
|
data = doc.to_dict()
|
||||||
|
currently_acknowledged = data.get("acknowledged", False)
|
||||||
|
now = datetime.now(timezone.utc).strftime("%d %B %Y at %H:%M:%S UTC")
|
||||||
|
|
||||||
|
if currently_acknowledged:
|
||||||
|
doc_ref.update({
|
||||||
|
"acknowledged": False,
|
||||||
|
"acknowledged_by": "",
|
||||||
|
"acknowledged_at": "",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
doc_ref.update({
|
||||||
|
"acknowledged": True,
|
||||||
|
"acknowledged_by": acknowledged_by,
|
||||||
|
"acknowledged_at": now,
|
||||||
|
})
|
||||||
|
|
||||||
|
updated_doc = doc_ref.get()
|
||||||
|
return _doc_to_message(db, updated_doc)
|
||||||
@@ -11,6 +11,7 @@ from users.router import router as users_router
|
|||||||
from mqtt.router import router as mqtt_router
|
from mqtt.router import router as mqtt_router
|
||||||
from equipment.router import router as equipment_router
|
from equipment.router import router as equipment_router
|
||||||
from staff.router import router as staff_router
|
from staff.router import router as staff_router
|
||||||
|
from helpdesk.router import router as helpdesk_router
|
||||||
from mqtt.client import mqtt_manager
|
from mqtt.client import mqtt_manager
|
||||||
from mqtt import database as mqtt_db
|
from mqtt import database as mqtt_db
|
||||||
from melodies import service as melody_service
|
from melodies import service as melody_service
|
||||||
@@ -37,6 +38,7 @@ app.include_router(settings_router)
|
|||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
app.include_router(mqtt_router)
|
app.include_router(mqtt_router)
|
||||||
app.include_router(equipment_router)
|
app.include_router(equipment_router)
|
||||||
|
app.include_router(helpdesk_router)
|
||||||
app.include_router(staff_router)
|
app.include_router(staff_router)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query, UploadFile, File
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_permission
|
from auth.dependencies import require_permission
|
||||||
@@ -93,3 +93,15 @@ async def unassign_device(
|
|||||||
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||||
):
|
):
|
||||||
return service.unassign_device(user_id, device_id)
|
return service.unassign_device(user_id, device_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/photo")
|
||||||
|
async def upload_photo(
|
||||||
|
user_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||||
|
):
|
||||||
|
contents = await file.read()
|
||||||
|
content_type = file.content_type or "image/jpeg"
|
||||||
|
url = service.upload_photo(user_id, contents, file.filename, content_type)
|
||||||
|
return {"photo_url": url}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from google.cloud.firestore_v1 import DocumentReference
|
from google.cloud.firestore_v1 import DocumentReference
|
||||||
|
|
||||||
from shared.firebase import get_db
|
from shared.firebase import get_db, get_bucket
|
||||||
from shared.exceptions import NotFoundError
|
from shared.exceptions import NotFoundError
|
||||||
from users.models import UserCreate, UserUpdate, UserInDB
|
from users.models import UserCreate, UserUpdate, UserInDB
|
||||||
|
|
||||||
@@ -250,3 +250,29 @@ def get_user_devices(user_doc_id: str) -> list[dict]:
|
|||||||
break
|
break
|
||||||
|
|
||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def upload_photo(user_doc_id: str, file_bytes: bytes, filename: str, content_type: str) -> str:
|
||||||
|
"""Upload a profile photo to Firebase Storage and update the user's photo_url."""
|
||||||
|
db = get_db()
|
||||||
|
bucket = get_bucket()
|
||||||
|
if not bucket:
|
||||||
|
raise RuntimeError("Firebase Storage not initialized")
|
||||||
|
|
||||||
|
# Verify user exists
|
||||||
|
doc_ref = db.collection(COLLECTION).document(user_doc_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("User")
|
||||||
|
|
||||||
|
ext = filename.rsplit(".", 1)[-1] if "." in filename else "jpg"
|
||||||
|
storage_path = f"users/{user_doc_id}/uploads/profile.{ext}"
|
||||||
|
|
||||||
|
blob = bucket.blob(storage_path)
|
||||||
|
blob.upload_from_string(file_bytes, content_type=content_type)
|
||||||
|
blob.make_public()
|
||||||
|
|
||||||
|
photo_url = blob.public_url
|
||||||
|
doc_ref.update({"photo_url": photo_url})
|
||||||
|
|
||||||
|
return photo_url
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
patch(endpoint, data) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
delete(endpoint) {
|
delete(endpoint) {
|
||||||
return this.request(endpoint, { method: "DELETE" });
|
return this.request(endpoint, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,38 +485,14 @@ export default function DeviceDetail() {
|
|||||||
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Device Information</h2>
|
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Device Information</h2>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "1rem", alignItems: "start" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "1rem", alignItems: "start" }}>
|
||||||
{/* Col 1: Status on top, device image below */}
|
{/* Col 1: Device image */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", height: "100%" }}>
|
<div style={{ display: "flex", alignItems: "center", height: "100%" }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
|
|
||||||
style={{ backgroundColor: isOnline ? "var(--success-bg)" : "var(--bg-card-hover)" }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="w-3 h-3 rounded-full inline-block"
|
|
||||||
style={{ backgroundColor: isOnline ? "var(--success-text)" : "var(--text-muted)" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Status</div>
|
|
||||||
<div className="text-sm font-semibold" style={{ color: isOnline ? "var(--success-text)" : "var(--text-muted)" }}>
|
|
||||||
{isOnline ? "Online" : "Offline"}
|
|
||||||
{mqttStatus && (
|
|
||||||
<span className="ml-2 text-xs font-normal" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{mqttStatus.seconds_since_heartbeat}s ago
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
||||||
<img
|
<img
|
||||||
src={hwImage}
|
src={hwImage}
|
||||||
alt={hwVariant}
|
alt={hwVariant}
|
||||||
style={{ maxHeight: 80, maxWidth: "100%", objectFit: "contain", opacity: 0.85 }}
|
style={{ maxHeight: 120, maxWidth: "100%", objectFit: "contain", opacity: 0.85 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Col 2: Serial Number, Hardware Variant, Document ID */}
|
{/* Col 2: Serial Number, Hardware Variant, Document ID */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
@@ -785,23 +761,35 @@ export default function DeviceDetail() {
|
|||||||
{attr.bellOutputs.map((output, i) => (
|
{attr.bellOutputs.map((output, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="relative rounded-md border px-4 py-3 text-center overflow-hidden"
|
className="rounded-md border overflow-hidden"
|
||||||
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)", minWidth: 90 }}
|
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)", minWidth: 120 }}
|
||||||
>
|
>
|
||||||
<span
|
<div style={{ display: "flex", alignItems: "stretch" }}>
|
||||||
className="absolute inset-0 flex items-center justify-center font-bold pointer-events-none select-none"
|
<div
|
||||||
style={{ fontSize: "3rem", color: "var(--text-heading)", opacity: 0.06 }}
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 44,
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--text-heading)",
|
||||||
|
opacity: 0.15,
|
||||||
|
borderRight: "1px solid var(--border-primary)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</span>
|
</div>
|
||||||
<div className="relative">
|
<div style={{ padding: "0.5rem 0.75rem" }}>
|
||||||
<div className="text-xs" style={{ color: "var(--text-muted)" }}>
|
<div className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
Output <span style={{ color: "var(--text-primary)" }}>{output}</span>
|
Output <span style={{ color: "var(--text-primary)" }}>{output}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
<div className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
{attr.hammerTimings?.[i] != null ? (
|
{attr.hammerTimings?.[i] != null ? (
|
||||||
<><span style={{ color: "var(--text-primary)" }}>{attr.hammerTimings[i]}</span> ms</>
|
<>Timing <span style={{ color: "var(--text-primary)" }}>{attr.hammerTimings[i]}</span> ms</>
|
||||||
) : "-"}
|
) : "Timing -"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -920,18 +908,29 @@ export default function DeviceDetail() {
|
|||||||
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}
|
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}
|
||||||
onClick={() => user.user_id && navigate(`/users/${user.user_id}`)}
|
onClick={() => user.user_id && navigate(`/users/${user.user_id}`)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full overflow-hidden shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)" }}
|
||||||
|
>
|
||||||
|
{user.photo_url ? (
|
||||||
|
<img src={user.photo_url} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-xs font-bold" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{(user.display_name || user.email || "?").charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}>
|
<p className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}>
|
||||||
{user.display_name || user.email || "Unknown User"}
|
{user.display_name || user.email || "Unknown User"}
|
||||||
</p>
|
</p>
|
||||||
{user.email && user.display_name && (
|
{user.email && user.display_name && (
|
||||||
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>{user.email}</p>
|
<p className="text-xs truncate" style={{ color: "var(--text-muted)", opacity: 0.7 }}>{user.email}</p>
|
||||||
)}
|
|
||||||
{user.user_id && (
|
|
||||||
<p className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{user.user_id}</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{user.role && (
|
{user.role && (
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full capitalize shrink-0 ml-2" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
<span className="px-2 py-0.5 text-xs rounded-full capitalize shrink-0 ml-2" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
||||||
{user.role}
|
{user.role}
|
||||||
@@ -1026,11 +1025,20 @@ export default function DeviceDetail() {
|
|||||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
{device.device_name || "Unnamed Device"}
|
{device.device_name || "Unnamed Device"}
|
||||||
</h1>
|
</h1>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<span
|
<span
|
||||||
className={`inline-block w-3 h-3 rounded-full ${isOnline ? "bg-green-500" : ""}`}
|
className="w-3 h-3 rounded-full inline-block"
|
||||||
style={!isOnline ? { backgroundColor: "var(--border-primary)" } : undefined}
|
style={{ backgroundColor: isOnline ? "var(--success-text)" : "var(--text-muted)" }}
|
||||||
title={isOnline ? "Online" : "Offline"}
|
|
||||||
/>
|
/>
|
||||||
|
<span className="text-sm font-semibold" style={{ color: isOnline ? "var(--success-text)" : "var(--text-muted)" }}>
|
||||||
|
{isOnline ? "Online" : "Offline"}
|
||||||
|
{mqttStatus && (
|
||||||
|
<span className="ml-2 text-xs font-normal" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{mqttStatus.seconds_since_heartbeat}s ago
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import api from "../api/client";
|
import api from "../api/client";
|
||||||
|
|
||||||
const CATEGORIES = ["general", "maintenance", "installation", "issue", "other"];
|
const CATEGORIES = ["general", "maintenance", "installation", "issue", "action_item", "other"];
|
||||||
|
|
||||||
export default function NoteForm() {
|
export default function NoteForm() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -193,7 +193,7 @@ export default function NoteForm() {
|
|||||||
>
|
>
|
||||||
{CATEGORIES.map((c) => (
|
{CATEGORIES.map((c) => (
|
||||||
<option key={c} value={c}>
|
<option key={c} value={c}>
|
||||||
{c.charAt(0).toUpperCase() + c.slice(1)}
|
{c.replace(/_/g, " ").replace(/\b\w/g, ch => ch.toUpperCase())}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import { useAuth } from "../auth/AuthContext";
|
|||||||
import SearchBar from "../components/SearchBar";
|
import SearchBar from "../components/SearchBar";
|
||||||
import ConfirmDialog from "../components/ConfirmDialog";
|
import ConfirmDialog from "../components/ConfirmDialog";
|
||||||
|
|
||||||
const CATEGORY_OPTIONS = ["", "general", "maintenance", "installation", "issue", "other"];
|
const NOTE_CATEGORIES = ["general", "maintenance", "installation", "other"];
|
||||||
|
const ISSUE_CATEGORIES = ["issue", "action_item"];
|
||||||
|
|
||||||
const categoryStyle = (cat) => {
|
const categoryStyle = (cat) => {
|
||||||
switch (cat) {
|
switch (cat) {
|
||||||
case "issue":
|
case "issue":
|
||||||
return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" };
|
return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" };
|
||||||
|
case "action_item":
|
||||||
|
return { backgroundColor: "var(--badge-blue-bg, rgba(59,130,246,0.15))", color: "var(--badge-blue-text, #3b82f6)" };
|
||||||
case "maintenance":
|
case "maintenance":
|
||||||
return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" };
|
return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" };
|
||||||
case "installation":
|
case "installation":
|
||||||
@@ -20,15 +23,30 @@ const categoryStyle = (cat) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const helpdeskTypeStyle = (type) => {
|
||||||
|
switch (type?.toLowerCase()) {
|
||||||
|
case "problem":
|
||||||
|
return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" };
|
||||||
|
case "suggestion":
|
||||||
|
return { backgroundColor: "var(--badge-blue-bg, rgba(59,130,246,0.15))", color: "var(--badge-blue-text, #3b82f6)" };
|
||||||
|
case "question":
|
||||||
|
return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" };
|
||||||
|
default:
|
||||||
|
return { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLabel = (s) => s ? s.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()) : "";
|
||||||
|
|
||||||
export default function NoteList() {
|
export default function NoteList() {
|
||||||
const [notes, setNotes] = useState([]);
|
const [notes, setNotes] = useState([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [helpdeskMessages, setHelpdeskMessages] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [categoryFilter, setCategoryFilter] = useState("");
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
const [hoveredRow, setHoveredRow] = useState(null);
|
const [hoveredRow, setHoveredRow] = useState(null);
|
||||||
|
const [activeTab, setActiveTab] = useState("notes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canEdit = hasPermission("equipment", "edit");
|
const canEdit = hasPermission("equipment", "edit");
|
||||||
@@ -43,13 +61,11 @@ export default function NoteList() {
|
|||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (search) params.set("search", search);
|
if (search) params.set("search", search);
|
||||||
if (categoryFilter) params.set("category", categoryFilter);
|
|
||||||
if (deviceIdFilter) params.set("device_id", deviceIdFilter);
|
if (deviceIdFilter) params.set("device_id", deviceIdFilter);
|
||||||
if (userIdFilter) params.set("user_id", userIdFilter);
|
if (userIdFilter) params.set("user_id", userIdFilter);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const data = await api.get(`/equipment/notes${qs ? `?${qs}` : ""}`);
|
const data = await api.get(`/equipment/notes${qs ? `?${qs}` : ""}`);
|
||||||
setNotes(data.notes);
|
setNotes(data.notes);
|
||||||
setTotal(data.total);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -57,9 +73,23 @@ export default function NoteList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchHelpdesk = async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (deviceIdFilter) params.set("device_id", deviceIdFilter);
|
||||||
|
if (userIdFilter) params.set("user_id", userIdFilter);
|
||||||
|
const qs = params.toString();
|
||||||
|
const data = await api.get(`/helpdesk${qs ? `?${qs}` : ""}`);
|
||||||
|
setHelpdeskMessages(data.messages);
|
||||||
|
} catch {
|
||||||
|
// Silently fail for helpdesk if not available
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchNotes();
|
fetchNotes();
|
||||||
}, [search, categoryFilter, deviceIdFilter, userIdFilter]);
|
fetchHelpdesk();
|
||||||
|
}, [search, deviceIdFilter, userIdFilter]);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
@@ -73,11 +103,233 @@ export default function NoteList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = async (note) => {
|
||||||
|
try {
|
||||||
|
const newStatus = note.status === "completed" ? "" : "completed";
|
||||||
|
await api.patch(`/equipment/notes/${note.id}/status`, { status: newStatus });
|
||||||
|
fetchNotes();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleAcknowledged = async (msg) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/helpdesk/${msg.id}/acknowledge`);
|
||||||
|
fetchHelpdesk();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Split notes by tab
|
||||||
|
const noteItems = notes.filter(n => NOTE_CATEGORIES.includes(n.category));
|
||||||
|
const issueItems = notes.filter(n => ISSUE_CATEGORIES.includes(n.category));
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: "notes", label: "Notes", count: noteItems.length },
|
||||||
|
{ key: "issues", label: "Issues & Action Items", count: issueItems.length },
|
||||||
|
{ key: "messages", label: "Client Messages", count: helpdeskMessages.length },
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderNotesTable = (items, showStatus = false) => {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-8 text-center text-sm border"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
No items found.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg overflow-hidden border"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
|
||||||
|
{showStatus && (
|
||||||
|
<th className="px-4 py-3 text-left font-medium w-12" style={{ color: "var(--text-secondary)" }} />
|
||||||
|
)}
|
||||||
|
<th className="px-4 py-3 text-left font-medium w-28" style={{ color: "var(--text-secondary)" }}>Category</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Title</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Device</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>User</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Created</th>
|
||||||
|
{canEdit && (
|
||||||
|
<th className="px-4 py-3 text-left font-medium w-24" style={{ color: "var(--text-secondary)" }} />
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((note, index) => (
|
||||||
|
<tr
|
||||||
|
key={note.id}
|
||||||
|
onClick={() => navigate(`/equipment/notes/${note.id}`)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
style={{
|
||||||
|
borderBottom: index < items.length - 1 ? "1px solid var(--border-primary)" : "none",
|
||||||
|
backgroundColor: hoveredRow === note.id ? "var(--bg-card-hover)" : "transparent",
|
||||||
|
opacity: note.status === "completed" ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredRow(note.id)}
|
||||||
|
onMouseLeave={() => setHoveredRow(null)}
|
||||||
|
>
|
||||||
|
{showStatus && (
|
||||||
|
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{canEdit ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleStatus(note)}
|
||||||
|
className="w-5 h-5 rounded border-2 flex items-center justify-center cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: note.status === "completed" ? "var(--success-text)" : "var(--border-primary)",
|
||||||
|
backgroundColor: note.status === "completed" ? "var(--success-bg)" : "transparent",
|
||||||
|
color: "var(--success-text)",
|
||||||
|
}}
|
||||||
|
title={note.status === "completed" ? "Mark as open" : "Mark as completed"}
|
||||||
|
>
|
||||||
|
{note.status === "completed" && "✓"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="w-5 h-5 rounded border-2 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
borderColor: note.status === "completed" ? "var(--success-text)" : "var(--border-primary)",
|
||||||
|
backgroundColor: note.status === "completed" ? "var(--success-bg)" : "transparent",
|
||||||
|
color: "var(--success-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{note.status === "completed" && "✓"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full" style={categoryStyle(note.category)}>
|
||||||
|
{formatLabel(note.category) || "General"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-medium" style={{
|
||||||
|
color: "var(--text-heading)",
|
||||||
|
textDecoration: note.status === "completed" ? "line-through" : "none",
|
||||||
|
}}>
|
||||||
|
{note.title || "Untitled"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{note.device_name || "-"}</td>
|
||||||
|
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{note.user_name || "-"}</td>
|
||||||
|
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{note.created_at || "-"}</td>
|
||||||
|
{canEdit && (
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={() => navigate(`/equipment/notes/${note.id}/edit`)} className="hover:opacity-80 text-xs cursor-pointer" style={{ color: "var(--text-link)" }}>Edit</button>
|
||||||
|
<button onClick={() => setDeleteTarget(note)} className="hover:opacity-80 text-xs cursor-pointer" style={{ color: "var(--danger)" }}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderHelpdeskMessages = () => {
|
||||||
|
const filtered = search
|
||||||
|
? helpdeskMessages.filter(m =>
|
||||||
|
(m.subject || "").toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(m.message || "").toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: helpdeskMessages;
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-8 text-center text-sm border"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
No client messages found.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg overflow-hidden border"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
|
||||||
|
<th className="px-4 py-3 text-left font-medium w-12" style={{ color: "var(--text-secondary)" }} />
|
||||||
|
<th className="px-4 py-3 text-left font-medium w-28" style={{ color: "var(--text-secondary)" }}>Type</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Subject</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>From</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Phone</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((msg, index) => (
|
||||||
|
<tr
|
||||||
|
key={msg.id}
|
||||||
|
style={{
|
||||||
|
borderBottom: index < filtered.length - 1 ? "1px solid var(--border-primary)" : "none",
|
||||||
|
backgroundColor: hoveredRow === msg.id ? "var(--bg-card-hover)" : "transparent",
|
||||||
|
opacity: msg.acknowledged ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredRow(msg.id)}
|
||||||
|
onMouseLeave={() => setHoveredRow(null)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleToggleAcknowledged(msg); }}
|
||||||
|
className="w-5 h-5 rounded border-2 flex items-center justify-center cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: msg.acknowledged ? "var(--success-text)" : "var(--border-primary)",
|
||||||
|
backgroundColor: msg.acknowledged ? "var(--success-bg)" : "transparent",
|
||||||
|
color: "var(--success-text)",
|
||||||
|
}}
|
||||||
|
title={msg.acknowledged ? `Acknowledged by ${msg.acknowledged_by}` : "Mark as acknowledged"}
|
||||||
|
>
|
||||||
|
{msg.acknowledged && "✓"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full" style={helpdeskTypeStyle(msg.type)}>
|
||||||
|
{msg.type || "Other"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium" style={{ color: "var(--text-heading)" }}>{msg.subject || "No subject"}</div>
|
||||||
|
<div className="text-xs truncate mt-0.5" style={{ color: "var(--text-muted)", maxWidth: 300 }}>{msg.message}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{msg.sender_name || "-"}</td>
|
||||||
|
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{msg.phone || "-"}</td>
|
||||||
|
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{msg.date_sent || "-"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Equipment Notes</h1>
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Issues and Notes</h1>
|
||||||
{canEdit && (
|
{canEdit && activeTab !== "messages" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -94,40 +346,36 @@ export default function NoteList() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 space-y-3">
|
{/* Tabs */}
|
||||||
<SearchBar onSearch={setSearch} placeholder="Search by title or content..." />
|
<div className="flex gap-1 mb-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
{tabs.map((tab) => (
|
||||||
<select
|
<button
|
||||||
value={categoryFilter}
|
key={tab.key}
|
||||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className="px-3 py-2 rounded-md text-sm cursor-pointer border"
|
className="px-4 py-2.5 text-sm font-medium transition-colors cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--bg-card)",
|
color: activeTab === tab.key ? "var(--accent)" : "var(--text-muted)",
|
||||||
color: "var(--text-primary)",
|
borderBottom: activeTab === tab.key ? "2px solid var(--accent)" : "2px solid transparent",
|
||||||
borderColor: "var(--border-primary)",
|
marginBottom: "-1px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">All Categories</option>
|
{tab.label}
|
||||||
{CATEGORY_OPTIONS.filter(Boolean).map((c) => (
|
<span className="ml-2 text-xs" style={{ opacity: 0.7 }}>({tab.count})</span>
|
||||||
<option key={c} value={c}>
|
</button>
|
||||||
{c.charAt(0).toUpperCase() + c.slice(1)}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
|
||||||
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{total} {total === 1 ? "note" : "notes"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<SearchBar
|
||||||
|
onSearch={setSearch}
|
||||||
|
placeholder={activeTab === "messages" ? "Search by subject or message..." : "Search by title or content..."}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
className="text-sm rounded-md p-3 mb-4 border"
|
className="text-sm rounded-md p-3 mb-4 border"
|
||||||
style={{
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
||||||
backgroundColor: "var(--danger-bg)",
|
|
||||||
borderColor: "var(--danger)",
|
|
||||||
color: "var(--danger-text)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
@@ -135,98 +383,12 @@ export default function NoteList() {
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||||
) : notes.length === 0 ? (
|
|
||||||
<div
|
|
||||||
className="rounded-lg p-8 text-center text-sm border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-card)",
|
|
||||||
borderColor: "var(--border-primary)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No notes found.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div
|
<>
|
||||||
className="rounded-lg overflow-hidden border"
|
{activeTab === "notes" && renderNotesTable(noteItems, false)}
|
||||||
style={{
|
{activeTab === "issues" && renderNotesTable(issueItems, true)}
|
||||||
backgroundColor: "var(--bg-card)",
|
{activeTab === "messages" && renderHelpdeskMessages()}
|
||||||
borderColor: "var(--border-primary)",
|
</>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
|
|
||||||
<th className="px-4 py-3 text-left font-medium w-28" style={{ color: "var(--text-secondary)" }}>Category</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Title</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Device</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>User</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Created</th>
|
|
||||||
{canEdit && (
|
|
||||||
<th className="px-4 py-3 text-left font-medium w-24" style={{ color: "var(--text-secondary)" }} />
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{notes.map((note, index) => (
|
|
||||||
<tr
|
|
||||||
key={note.id}
|
|
||||||
onClick={() => navigate(`/equipment/notes/${note.id}`)}
|
|
||||||
className="cursor-pointer"
|
|
||||||
style={{
|
|
||||||
borderBottom: index < notes.length - 1 ? "1px solid var(--border-primary)" : "none",
|
|
||||||
backgroundColor: hoveredRow === note.id ? "var(--bg-card-hover)" : "transparent",
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setHoveredRow(note.id)}
|
|
||||||
onMouseLeave={() => setHoveredRow(null)}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span
|
|
||||||
className="px-2 py-0.5 text-xs rounded-full"
|
|
||||||
style={categoryStyle(note.category)}
|
|
||||||
>
|
|
||||||
{note.category || "general"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
|
|
||||||
{note.title || "Untitled"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{note.device_name || "-"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{note.user_name || "-"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{note.created_at || "-"}
|
|
||||||
</td>
|
|
||||||
{canEdit && (
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/equipment/notes/${note.id}/edit`)}
|
|
||||||
className="hover:opacity-80 text-xs cursor-pointer"
|
|
||||||
style={{ color: "var(--text-link)" }}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteTarget(note)}
|
|
||||||
className="hover:opacity-80 text-xs cursor-pointer"
|
|
||||||
style={{ color: "var(--danger)" }}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ import api from "../api/client";
|
|||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import ConfirmDialog from "../components/ConfirmDialog";
|
import ConfirmDialog from "../components/ConfirmDialog";
|
||||||
|
|
||||||
|
const NOTE_CATEGORIES = ["general", "maintenance", "installation", "other"];
|
||||||
|
const ISSUE_CATEGORIES = ["issue", "action_item"];
|
||||||
|
const ALL_CATEGORIES = ["general", "maintenance", "installation", "issue", "action_item", "other"];
|
||||||
|
|
||||||
const categoryStyle = (cat) => {
|
const categoryStyle = (cat) => {
|
||||||
switch (cat) {
|
switch (cat) {
|
||||||
case "issue":
|
case "issue":
|
||||||
return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" };
|
return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" };
|
||||||
|
case "action_item":
|
||||||
|
return { backgroundColor: "var(--badge-blue-bg, rgba(59,130,246,0.15))", color: "var(--badge-blue-text, #3b82f6)" };
|
||||||
case "maintenance":
|
case "maintenance":
|
||||||
return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" };
|
return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" };
|
||||||
case "installation":
|
case "installation":
|
||||||
@@ -17,10 +23,24 @@ const categoryStyle = (cat) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORIES = ["general", "maintenance", "installation", "issue", "other"];
|
const helpdeskTypeStyle = (type) => {
|
||||||
|
switch (type?.toLowerCase()) {
|
||||||
|
case "problem":
|
||||||
|
return { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" };
|
||||||
|
case "suggestion":
|
||||||
|
return { backgroundColor: "var(--badge-blue-bg, rgba(59,130,246,0.15))", color: "var(--badge-blue-text, #3b82f6)" };
|
||||||
|
case "question":
|
||||||
|
return { backgroundColor: "var(--warning-bg, rgba(245,158,11,0.15))", color: "var(--warning-text, #f59e0b)" };
|
||||||
|
default:
|
||||||
|
return { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLabel = (s) => s ? s.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()) : "";
|
||||||
|
|
||||||
export default function NotesPanel({ deviceId, userId }) {
|
export default function NotesPanel({ deviceId, userId }) {
|
||||||
const [notes, setNotes] = useState([]);
|
const [notes, setNotes] = useState([]);
|
||||||
|
const [helpdeskMessages, setHelpdeskMessages] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
@@ -29,6 +49,7 @@ export default function NotesPanel({ deviceId, userId }) {
|
|||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [category, setCategory] = useState("general");
|
const [category, setCategory] = useState("general");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState("notes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canEdit = hasPermission("equipment", "edit");
|
const canEdit = hasPermission("equipment", "edit");
|
||||||
@@ -50,8 +71,22 @@ export default function NotesPanel({ deviceId, userId }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchHelpdesk = async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (deviceId) params.set("device_id", deviceId);
|
||||||
|
if (userId) params.set("user_id", userId);
|
||||||
|
const qs = params.toString();
|
||||||
|
const data = await api.get(`/helpdesk${qs ? `?${qs}` : ""}`);
|
||||||
|
setHelpdeskMessages(data.messages);
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchNotes();
|
fetchNotes();
|
||||||
|
fetchHelpdesk();
|
||||||
}, [deviceId, userId]);
|
}, [deviceId, userId]);
|
||||||
|
|
||||||
const handleCreate = async (e) => {
|
const handleCreate = async (e) => {
|
||||||
@@ -90,22 +125,158 @@ export default function NotesPanel({ deviceId, userId }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = async (note) => {
|
||||||
|
try {
|
||||||
|
const newStatus = note.status === "completed" ? "" : "completed";
|
||||||
|
await api.patch(`/equipment/notes/${note.id}/status`, { status: newStatus });
|
||||||
|
fetchNotes();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleAcknowledged = async (msg) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/helpdesk/${msg.id}/acknowledge`);
|
||||||
|
fetchHelpdesk();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Split notes
|
||||||
|
const noteItems = notes.filter(n => NOTE_CATEGORIES.includes(n.category));
|
||||||
|
const issueItems = notes.filter(n => ISSUE_CATEGORIES.includes(n.category));
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: "notes", label: "Notes", count: noteItems.length },
|
||||||
|
{ key: "issues", label: "Issues", count: issueItems.length },
|
||||||
|
{ key: "messages", label: "Messages", count: helpdeskMessages.length },
|
||||||
|
];
|
||||||
|
|
||||||
|
const totalCount = notes.length + helpdeskMessages.length;
|
||||||
|
|
||||||
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
|
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
|
||||||
|
|
||||||
|
const renderNoteItem = (note, showStatus = false) => (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
className="p-3 rounded-md border cursor-pointer hover:opacity-90 transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
opacity: note.status === "completed" ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
onClick={() => navigate(`/equipment/notes/${note.id}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||||
|
{showStatus && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleToggleStatus(note); }}
|
||||||
|
className="w-5 h-5 rounded border-2 flex items-center justify-center cursor-pointer transition-colors shrink-0 mt-0.5"
|
||||||
|
style={{
|
||||||
|
borderColor: note.status === "completed" ? "var(--success-text)" : "var(--border-primary)",
|
||||||
|
backgroundColor: note.status === "completed" ? "var(--success-bg)" : "transparent",
|
||||||
|
color: "var(--success-text)",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
}}
|
||||||
|
title={note.status === "completed" ? "Mark as open" : "Mark as completed"}
|
||||||
|
>
|
||||||
|
{note.status === "completed" && "✓"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full shrink-0" style={categoryStyle(note.category)}>
|
||||||
|
{formatLabel(note.category)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-sm font-medium truncate"
|
||||||
|
style={{
|
||||||
|
color: "var(--text-heading)",
|
||||||
|
textDecoration: note.status === "completed" ? "line-through" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{note.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>{note.content}</p>
|
||||||
|
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{note.created_by && `${note.created_by} · `}{note.created_at}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDeleteTarget(note); }}
|
||||||
|
className="text-xs hover:opacity-80 shrink-0 cursor-pointer"
|
||||||
|
style={{ color: "var(--danger)" }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderHelpdeskItem = (msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className="p-3 rounded-md border transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
opacity: msg.acknowledged ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleAcknowledged(msg)}
|
||||||
|
className="w-5 h-5 rounded border-2 flex items-center justify-center cursor-pointer transition-colors shrink-0 mt-0.5"
|
||||||
|
style={{
|
||||||
|
borderColor: msg.acknowledged ? "var(--success-text)" : "var(--border-primary)",
|
||||||
|
backgroundColor: msg.acknowledged ? "var(--success-bg)" : "transparent",
|
||||||
|
color: "var(--success-text)",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
}}
|
||||||
|
title={msg.acknowledged ? `Acknowledged by ${msg.acknowledged_by}` : "Mark as acknowledged"}
|
||||||
|
>
|
||||||
|
{msg.acknowledged && "✓"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full shrink-0" style={helpdeskTypeStyle(msg.type)}>
|
||||||
|
{msg.type || "Other"}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{msg.subject || "No subject"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>{msg.message}</p>
|
||||||
|
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{msg.sender_name && `${msg.sender_name} · `}{msg.phone && `${msg.phone} · `}{msg.date_sent}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="rounded-lg border p-6"
|
className="rounded-lg border p-6"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2
|
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
|
||||||
className="text-lg font-semibold"
|
Issues & Notes ({totalCount})
|
||||||
style={{ color: "var(--text-heading)" }}
|
|
||||||
>
|
|
||||||
Notes ({notes.length})
|
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{canEdit && (
|
{canEdit && activeTab !== "messages" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowForm(!showForm)}
|
onClick={() => setShowForm(!showForm)}
|
||||||
className="px-3 py-1.5 text-xs rounded-md hover:opacity-90 transition-colors cursor-pointer"
|
className="px-3 py-1.5 text-xs rounded-md hover:opacity-90 transition-colors cursor-pointer"
|
||||||
@@ -129,20 +300,34 @@ export default function NotesPanel({ deviceId, userId }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Compact tabs */}
|
||||||
|
<div className="flex gap-1 mb-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className="px-3 py-2 text-xs font-medium transition-colors cursor-pointer"
|
||||||
|
style={{
|
||||||
|
color: activeTab === tab.key ? "var(--accent)" : "var(--text-muted)",
|
||||||
|
borderBottom: activeTab === tab.key ? "2px solid var(--accent)" : "2px solid transparent",
|
||||||
|
marginBottom: "-1px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label} ({tab.count})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
className="text-sm rounded-md p-3 mb-4 border"
|
className="text-sm rounded-md p-3 mb-4 border"
|
||||||
style={{
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
||||||
backgroundColor: "var(--danger-bg)",
|
|
||||||
borderColor: "var(--danger)",
|
|
||||||
color: "var(--danger-text)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showForm && (
|
{showForm && activeTab !== "messages" && (
|
||||||
<form onSubmit={handleCreate} className="mb-4 p-4 rounded-md border" style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}>
|
<form onSubmit={handleCreate} className="mb-4 p-4 rounded-md border" style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -154,27 +339,17 @@ export default function NotesPanel({ deviceId, userId }) {
|
|||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Note title"
|
placeholder="Note title"
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
style={{
|
style={{ backgroundColor: "var(--bg-card)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }}
|
||||||
backgroundColor: "var(--bg-card)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
borderColor: "var(--border-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={category}
|
value={category}
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
className="px-3 py-2 rounded-md text-sm border"
|
className="px-3 py-2 rounded-md text-sm border"
|
||||||
style={{
|
style={{ backgroundColor: "var(--bg-card)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }}
|
||||||
backgroundColor: "var(--bg-card)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
borderColor: "var(--border-primary)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{CATEGORIES.map((c) => (
|
{ALL_CATEGORIES.map((c) => (
|
||||||
<option key={c} value={c}>
|
<option key={c} value={c}>{formatLabel(c)}</option>
|
||||||
{c.charAt(0).toUpperCase() + c.slice(1)}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,12 +360,7 @@ export default function NotesPanel({ deviceId, userId }) {
|
|||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Note content..."
|
placeholder="Note content..."
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
style={{
|
style={{ backgroundColor: "var(--bg-card)", color: "var(--text-primary)", borderColor: "var(--border-primary)", resize: "vertical" }}
|
||||||
backgroundColor: "var(--bg-card)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
borderColor: "var(--border-primary)",
|
|
||||||
resize: "vertical",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
@@ -208,55 +378,30 @@ export default function NotesPanel({ deviceId, userId }) {
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm py-4 text-center" style={{ color: "var(--text-muted)" }}>Loading...</p>
|
<p className="text-sm py-4 text-center" style={{ color: "var(--text-muted)" }}>Loading...</p>
|
||||||
) : notes.length === 0 ? (
|
|
||||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
|
|
||||||
No notes yet.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
{notes.map((note) => (
|
{activeTab === "notes" && (
|
||||||
<div
|
noteItems.length === 0 ? (
|
||||||
key={note.id}
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No notes yet.</p>
|
||||||
className="p-3 rounded-md border cursor-pointer hover:opacity-90 transition-colors"
|
) : (
|
||||||
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}
|
<div className="space-y-2">{noteItems.map(n => renderNoteItem(n, false))}</div>
|
||||||
onClick={() => navigate(`/equipment/notes/${note.id}`)}
|
)
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span
|
|
||||||
className="px-2 py-0.5 text-xs rounded-full shrink-0"
|
|
||||||
style={categoryStyle(note.category)}
|
|
||||||
>
|
|
||||||
{note.category}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}>
|
|
||||||
{note.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{note.content}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{note.created_by && `${note.created_by} · `}{note.created_at}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{canEdit && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setDeleteTarget(note);
|
|
||||||
}}
|
|
||||||
className="text-xs hover:opacity-80 shrink-0 cursor-pointer"
|
|
||||||
style={{ color: "var(--danger)" }}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
{activeTab === "issues" && (
|
||||||
</div>
|
issueItems.length === 0 ? (
|
||||||
))}
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No issues or action items.</p>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="space-y-2">{issueItems.map(n => renderNoteItem(n, true))}</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{activeTab === "messages" && (
|
||||||
|
helpdeskMessages.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No client messages.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">{helpdeskMessages.map(renderHelpdeskItem)}</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const navItems = [
|
|||||||
{ to: "/mqtt/logs", label: "Logs" },
|
{ to: "/mqtt/logs", label: "Logs" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ to: "/equipment/notes", label: "Equipment Notes", permission: "equipment" },
|
{ to: "/equipment/notes", label: "Issues and Notes", permission: "equipment" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const linkClass = (isActive, locked) =>
|
const linkClass = (isActive, locked) =>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const SECTIONS = [
|
|||||||
{ key: "melodies", label: "Melodies" },
|
{ key: "melodies", label: "Melodies" },
|
||||||
{ key: "devices", label: "Devices" },
|
{ key: "devices", label: "Devices" },
|
||||||
{ key: "app_users", label: "App Users" },
|
{ key: "app_users", label: "App Users" },
|
||||||
{ key: "equipment", label: "Equipment Notes" },
|
{ key: "equipment", label: "Issues and Notes" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ACTIONS = ["view", "add", "edit", "delete"];
|
const ACTIONS = ["view", "add", "edit", "delete"];
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const SECTIONS = [
|
|||||||
{ key: "melodies", label: "Melodies" },
|
{ key: "melodies", label: "Melodies" },
|
||||||
{ key: "devices", label: "Devices" },
|
{ key: "devices", label: "Devices" },
|
||||||
{ key: "app_users", label: "App Users" },
|
{ key: "app_users", label: "App Users" },
|
||||||
{ key: "equipment", label: "Equipment Notes" },
|
{ key: "equipment", label: "Issues and Notes" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ACTIONS = ["view", "add", "edit", "delete"];
|
const ACTIONS = ["view", "add", "edit", "delete"];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import api from "../api/client";
|
import api from "../api/client";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
@@ -37,6 +37,8 @@ export default function UserDetail() {
|
|||||||
const [assigningDevice, setAssigningDevice] = useState(false);
|
const [assigningDevice, setAssigningDevice] = useState(false);
|
||||||
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
||||||
const [showAssignPanel, setShowAssignPanel] = useState(false);
|
const [showAssignPanel, setShowAssignPanel] = useState(false);
|
||||||
|
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||||
|
const photoInputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
@@ -126,6 +128,22 @@ export default function UserDetail() {
|
|||||||
loadAllDevices();
|
loadAllDevices();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePhotoUpload = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploadingPhoto(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await api.upload(`/users/${id}/photo`, file);
|
||||||
|
setUser((prev) => ({ ...prev, photo_url: result.photo_url }));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setUploadingPhoto(false);
|
||||||
|
if (photoInputRef.current) photoInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>
|
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>
|
||||||
@@ -247,7 +265,50 @@ export default function UserDetail() {
|
|||||||
>
|
>
|
||||||
Account Information
|
Account Information
|
||||||
</h2>
|
</h2>
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<div style={{ display: "flex", gap: "1.5rem" }}>
|
||||||
|
{/* Profile Photo */}
|
||||||
|
<div className="shrink-0" style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "0.5rem" }}>
|
||||||
|
<div
|
||||||
|
className="relative rounded-full overflow-hidden"
|
||||||
|
style={{ width: 80, height: 80, backgroundColor: "var(--bg-card-hover)" }}
|
||||||
|
>
|
||||||
|
{user.photo_url ? (
|
||||||
|
<img
|
||||||
|
src={user.photo_url}
|
||||||
|
alt={user.display_name || "User"}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-full h-full flex items-center justify-center text-2xl font-bold"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{(user.display_name || user.email || "?").charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => photoInputRef.current?.click()}
|
||||||
|
disabled={uploadingPhoto}
|
||||||
|
className="text-xs hover:opacity-80 cursor-pointer transition-colors"
|
||||||
|
style={{ color: "var(--text-link)" }}
|
||||||
|
>
|
||||||
|
{uploadingPhoto ? "Uploading..." : "Change Photo"}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={photoInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handlePhotoUpload}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Fields */}
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4 flex-1">
|
||||||
<Field label="Document ID">
|
<Field label="Document ID">
|
||||||
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>
|
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
{user.id}
|
{user.id}
|
||||||
@@ -274,6 +335,7 @@ export default function UserDetail() {
|
|||||||
<Field label="Phone">{user.phone_number}</Field>
|
<Field label="Phone">{user.phone_number}</Field>
|
||||||
<Field label="Title">{user.userTitle}</Field>
|
<Field label="Title">{user.userTitle}</Field>
|
||||||
</dl>
|
</dl>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Profile */}
|
{/* Profile */}
|
||||||
@@ -289,7 +351,6 @@ export default function UserDetail() {
|
|||||||
</h2>
|
</h2>
|
||||||
<dl className="grid grid-cols-1 gap-4">
|
<dl className="grid grid-cols-1 gap-4">
|
||||||
<Field label="Bio">{user.bio}</Field>
|
<Field label="Bio">{user.bio}</Field>
|
||||||
<Field label="Photo URL">{user.photo_url}</Field>
|
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -475,7 +536,7 @@ export default function UserDetail() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Equipment Notes */}
|
{/* Issues and Notes */}
|
||||||
<NotesPanel userId={id} />
|
<NotesPanel userId={id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user