feat: Kunden archivieren + Ticket-Status-Checkbox-Filter
Kunden: - Neues Feld customers.archived; Archivieren/Wiederherstellen-Button in der Kundendetailseite, 'Archiviert'-Badge - CustomersPage: vierter Filter-Tab 'Archiviert'; archivierte Kunden aus Alle/Leads/Feste Kunden ausgeblendet, nur im Archiv-Tab sichtbar Tickets: - Status-Mehrfachauswahl per Checkbox im Filterbereich; Default alle Status ausser 'Closed' (= archiviert). 'Standard' / 'Alle inkl. Archiv' Shortcuts - useWorkorders: Statusfilter serverseitig via Query.equal(array) statt Client-Filter nach dem Limit (behebt Bug, bei dem archivierte Tickets die neuesten Slots belegten und aktive verdraengten) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -117,22 +117,19 @@ export function useWorkorders(filters = {}) {
|
||||
queries.push(Query.limit(activeFilters.limit))
|
||||
}
|
||||
|
||||
// Appwrite Query.equal mit Array = OR-Match. Serverseitig filtern, damit
|
||||
// das Limit erst NACH dem Statusfilter greift (sonst wuerden archivierte
|
||||
// Tickets die neuesten Slots belegen und aktive verdraengen).
|
||||
if (activeFilters.status && activeFilters.status.length > 0) {
|
||||
if (activeFilters.status.length === 1) {
|
||||
queries.push(Query.equal('status', activeFilters.status[0]))
|
||||
}
|
||||
queries.push(Query.equal('status', activeFilters.status))
|
||||
}
|
||||
|
||||
|
||||
if (activeFilters.type && activeFilters.type.length > 0) {
|
||||
if (activeFilters.type.length === 1) {
|
||||
queries.push(Query.equal('type', activeFilters.type[0]))
|
||||
}
|
||||
queries.push(Query.equal('type', activeFilters.type))
|
||||
}
|
||||
|
||||
|
||||
if (activeFilters.priority && activeFilters.priority.length > 0) {
|
||||
if (activeFilters.priority.length === 1) {
|
||||
queries.push(Query.equal('priority', activeFilters.priority[0]))
|
||||
}
|
||||
queries.push(Query.equal('priority', activeFilters.priority))
|
||||
}
|
||||
|
||||
if (activeFilters.customerId) {
|
||||
@@ -156,23 +153,8 @@ export function useWorkorders(filters = {}) {
|
||||
COLLECTIONS.WORKORDERS,
|
||||
queries
|
||||
)
|
||||
|
||||
// Clientseitige Filterung für Arrays (da Query.or() nicht verfügbar ist)
|
||||
let filteredDocs = response.documents
|
||||
|
||||
if (activeFilters.status && activeFilters.status.length > 1) {
|
||||
filteredDocs = filteredDocs.filter(doc => activeFilters.status.includes(doc.status))
|
||||
}
|
||||
|
||||
if (activeFilters.type && activeFilters.type.length > 1) {
|
||||
filteredDocs = filteredDocs.filter(doc => activeFilters.type.includes(doc.type))
|
||||
}
|
||||
|
||||
if (activeFilters.priority && activeFilters.priority.length > 1) {
|
||||
filteredDocs = filteredDocs.filter(doc => activeFilters.priority.includes(doc.priority))
|
||||
}
|
||||
|
||||
setWorkorders(filteredDocs)
|
||||
|
||||
setWorkorders(response.documents)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
|
||||
|
||||
@@ -13,6 +13,19 @@ export function customerStage(c) {
|
||||
return 'customer' // Bestandsdaten ohne Status gelten als fester Kunde
|
||||
}
|
||||
|
||||
/** Archiviert = aus der Standardansicht ausgeblendet (orthogonal zur CRM-Stufe). */
|
||||
export function isArchived(c) {
|
||||
return Boolean(c?.archived)
|
||||
}
|
||||
|
||||
/** Kunde archivieren / wiederherstellen. */
|
||||
export async function setCustomerArchived(customer, archived) {
|
||||
return databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
|
||||
archived: Boolean(archived),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
function ninjaPayload(c) {
|
||||
return {
|
||||
name: c.companyName || c.name,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaArrowUpRightFromSquare } from 'react-icons/fa6'
|
||||
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaArrowUpRightFromSquare, FaBoxArchive, FaBoxOpen } from 'react-icons/fa6'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
|
||||
import { StatusPill, PriorityPill } from '../components/ui/StatusPill'
|
||||
@@ -10,7 +10,7 @@ import AssignedProjectCard from '../components/AssignedProjectCard'
|
||||
import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential'
|
||||
import { updateCustomerWithPortalAccess } from '../lib/customerAdminApi'
|
||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||
import { customerStage, isInvoiceReady, upgradeToCustomer, syncCustomerToNinja } from '../lib/leads'
|
||||
import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer, syncCustomerToNinja } from '../lib/leads'
|
||||
|
||||
const PORTAL_URL = 'https://project.webklar.com'
|
||||
|
||||
@@ -47,8 +47,14 @@ export default function CustomerDetailPage() {
|
||||
if (error || !customer) return <div className="page"><EmptyState title="Kunde nicht gefunden" hint={error} action={<Button as={Link} to="/customers"><FaArrowLeft /> Zurueck</Button>} /></div>
|
||||
|
||||
const stage = customerStage(customer)
|
||||
const archived = isArchived(customer)
|
||||
const syntheticTicket = { customerId: customer.$id, customerName: customer.name, woid: '' }
|
||||
|
||||
const toggleArchived = async () => {
|
||||
await setCustomerArchived(customer, !archived)
|
||||
await load()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
@@ -59,10 +65,18 @@ export default function CustomerDetailPage() {
|
||||
{stage === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
|
||||
{stage === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
|
||||
{stage === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
|
||||
{archived && <Badge tone="muted"><FaBoxArchive /> Archiviert</Badge>}
|
||||
</span>
|
||||
}
|
||||
subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' <20> ')}
|
||||
actions={<Button variant="ghost" as={Link} to="/customers"><FaArrowLeft /> Alle Kunden</Button>}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" onClick={toggleArchived}>
|
||||
{archived ? <><FaBoxOpen /> Wiederherstellen</> : <><FaBoxArchive /> Archivieren</>}
|
||||
</Button>
|
||||
<Button variant="ghost" as={Link} to="/customers"><FaArrowLeft /> Alle Kunden</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||
|
||||
@@ -3,19 +3,23 @@ import { Link } from 'react-router-dom'
|
||||
import { FaSpinner, FaPlus, FaStar, FaFileInvoiceDollar, FaFileLines } from 'react-icons/fa6'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { createLead, customerStage } from '../lib/leads'
|
||||
import { createLead, customerStage, isArchived } from '../lib/leads'
|
||||
import { PageHeader, Card, Button, Badge, EmptyState, Field } from '../components/ui'
|
||||
|
||||
export default function CustomersPage() {
|
||||
const { customers, loading, refresh } = useCustomers()
|
||||
const { user } = useAuth()
|
||||
const [search, setSearch] = useState('')
|
||||
const [stage, setStage] = useState('all') // all | lead | customer
|
||||
const [stage, setStage] = useState('all') // all | lead | customer | archived
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return (customers || [])
|
||||
.filter((c) => {
|
||||
const archived = isArchived(c)
|
||||
// Archivierte nur im Archiviert-Tab, sonst nie
|
||||
if (stage === 'archived') return archived
|
||||
if (archived) return false
|
||||
const st = customerStage(c)
|
||||
if (st === 'lost') return stage === 'all'
|
||||
if (stage === 'all') return true
|
||||
@@ -56,6 +60,7 @@ export default function CustomersPage() {
|
||||
<button className={stage === 'all' ? 'active' : ''} onClick={() => setStage('all')}>Alle</button>
|
||||
<button className={stage === 'lead' ? 'active' : ''} onClick={() => setStage('lead')}>Leads</button>
|
||||
<button className={stage === 'customer' ? 'active' : ''} onClick={() => setStage('customer')}>Feste Kunden</button>
|
||||
<button className={stage === 'archived' ? 'active' : ''} onClick={() => setStage('archived')}>Archiviert</button>
|
||||
</div>
|
||||
<input className="form-control search-input" placeholder="Kunde suchen..." value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 320 }} />
|
||||
</div>
|
||||
@@ -77,11 +82,12 @@ export default function CustomersPage() {
|
||||
<div style={{ fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{c.importantCustomer && <FaStar style={{ color: 'var(--warn)' }} title="Wichtiger Kunde" />}
|
||||
{c.name}
|
||||
{c.code && <span className="faint" style={{ fontWeight: 400 }}><EFBFBD> {c.code}</span>}
|
||||
{c.code && <span className="faint" style={{ fontWeight: 400 }}><EFBFBD> {c.code}</span>}
|
||||
</div>
|
||||
<div className="muted">{[c.location, c.email].filter(Boolean).join(' <20> ') || '-'}</div>
|
||||
<div className="muted">{[c.location, c.email].filter(Boolean).join(' <20> ') || '-'}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 wrap" style={{ justifyContent: 'flex-end' }}>
|
||||
{isArchived(c) && <Badge tone="muted">Archiviert</Badge>}
|
||||
{st === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
|
||||
{st === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
|
||||
{st === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
|
||||
|
||||
@@ -7,15 +7,28 @@ import CreateTicketModal from '../components/CreateTicketModal'
|
||||
import QuickOverviewModal from '../components/QuickOverviewModal'
|
||||
import { PageHeader, Card, Button, EmptyState } from '../components/ui'
|
||||
|
||||
const STATUS_PRESETS = ['Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info']
|
||||
// Alle Ticket-Status; "Closed" gilt als archiviert und ist standardmaessig ausgeblendet
|
||||
const ALL_STATUSES = [
|
||||
'Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info',
|
||||
'In Test', 'Halted', 'Aborted', 'Cancelled', 'Closed',
|
||||
]
|
||||
const ARCHIVED_STATUSES = ['Closed']
|
||||
const DEFAULT_STATUSES = ALL_STATUSES.filter((s) => !ARCHIVED_STATUSES.includes(s))
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [limit, setLimit] = useState(10)
|
||||
const [filters, setFilters] = useState({ status: STATUS_PRESETS, type: [], priority: [], limit: 10 })
|
||||
const [filters, setFilters] = useState({ status: DEFAULT_STATUSES, type: [], priority: [], limit: 10 })
|
||||
const [statusSel, setStatusSel] = useState(DEFAULT_STATUSES)
|
||||
const [localFilters, setLocalFilters] = useState({
|
||||
woid: '', customer: '', userTopic: '', createdDate: '', type: '', system: '', priority: '',
|
||||
})
|
||||
|
||||
const toggleStatus = (status) => {
|
||||
setStatusSel((prev) =>
|
||||
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
|
||||
)
|
||||
}
|
||||
|
||||
const { workorders, loading, error, refresh, updateWorkorder, createWorkorder } = useWorkorders(filters)
|
||||
const { customers } = useCustomers()
|
||||
|
||||
@@ -26,6 +39,7 @@ export default function TicketsPage() {
|
||||
const applyFilters = () => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
status: statusSel.length ? statusSel : ALL_STATUSES,
|
||||
woid: localFilters.woid || undefined,
|
||||
customer: localFilters.customer || undefined,
|
||||
userTopic: localFilters.userTopic || undefined,
|
||||
@@ -92,6 +106,22 @@ export default function TicketsPage() {
|
||||
<option value="3">Hoch</option><option value="4">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div className="flex gap-2 wrap" style={{ alignItems: 'center', marginBottom: 8 }}>
|
||||
<span className="muted" style={{ fontSize: 13, fontWeight: 600 }}>Status</span>
|
||||
<button type="button" onClick={() => setStatusSel(DEFAULT_STATUSES)} style={{ fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 }}>Standard</button>
|
||||
<button type="button" onClick={() => setStatusSel(ALL_STATUSES)} style={{ fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 }}>Alle inkl. Archiv</button>
|
||||
</div>
|
||||
<div className="flex gap-3 wrap">
|
||||
{ALL_STATUSES.map((status) => (
|
||||
<label key={status} className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 14 }}>
|
||||
<input type="checkbox" checked={statusSel.includes(status)} onChange={() => toggleStatus(status)} />
|
||||
{status}
|
||||
{ARCHIVED_STATUSES.includes(status) && <span className="faint" style={{ fontSize: 11 }}>(Archiv)</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 wrap" style={{ marginTop: 12 }}>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, priority: [4] })); setTimeout(refresh, 0) }}>Kritische</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setFilters((p) => ({ ...p, priority: [3] })); setTimeout(refresh, 0) }}>Hohe</Button>
|
||||
|
||||
Reference in New Issue
Block a user