sozsoft-platform/ui/src/views/developerKit/sqlViewDesigner/CriteriaGrid.tsx
2026-08-19 10:50:33 +03:00

472 lines
18 KiB
TypeScript

/**
* View Designer — Criteria grid (SSMS Query Designer'in orta bolumu).
*
* Sutunlar: Column · Alias · Table · Output · Group By · Sort Type · Sort Order
* · Filter · Or... Filtre hucreleri serbest yuklemdir ("> 100", "LIKE '%a%'");
* yaninda kucuk bir yardimci ile de doldurulabilir.
*/
import { useState } from 'react'
import { FaCode, FaGripVertical, FaMagic, FaPlus, FaTrash } from 'react-icons/fa'
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
import {
GROUP_MODES,
GROUP_MODE_LABEL,
isAggregateMode,
orColumnCount,
quoteLiteral,
type CriteriaRow,
type GroupMode,
type ViewSource,
} from './viewModel'
interface CriteriaGridProps {
rows: CriteriaRow[]
sources: ViewSource[]
columnsOf: (sourceId: string) => DatabaseColumnDto[]
grouped: boolean
onUpdateRow: (id: string, patch: Partial<CriteriaRow>) => void
onUpdateFilter: (id: string, index: number, value: string) => void
onRemoveRow: (id: string) => void
onMoveRow: (id: string, delta: number) => void
onAddRow: () => void
onAddExpression: () => void
labels: {
column: string
alias: string
table: string
output: string
groupBy: string
sortType: string
sortOrder: string
filter: string
or: string
addRow: string
addExpression: string
expressionPlaceholder: string
empty: string
builder: string
builderValue: string
builderLiteral: string
builderExpression: string
apply: string
}
}
const OPERATORS = [
'=',
'<>',
'>',
'>=',
'<',
'<=',
'LIKE',
'NOT LIKE',
'IN',
'NOT IN',
'IS NULL',
'IS NOT NULL',
'BETWEEN',
]
export function CriteriaGrid({
rows,
sources,
columnsOf,
grouped,
onUpdateRow,
onUpdateFilter,
onRemoveRow,
onMoveRow,
onAddRow,
onAddExpression,
labels,
}: CriteriaGridProps) {
const [builder, setBuilder] = useState<{ rowId: string; index: number } | null>(null)
const orCount = orColumnCount(rows)
const cellCls =
'w-full rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-gray-300 focus:border-blue-500 focus:bg-white dark:text-gray-100 dark:hover:border-gray-600 dark:focus:bg-gray-700'
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 border-b border-gray-200 bg-gray-50 px-2 py-1 dark:border-gray-700 dark:bg-gray-800">
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30"
onClick={onAddRow}
disabled={sources.length === 0}
>
<FaPlus className="text-[9px]" /> {labels.addRow}
</button>
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/30"
onClick={onAddExpression}
>
<FaCode className="text-[9px]" /> {labels.addExpression}
</button>
<span className="text-[10px] text-gray-400">{rows.length}</span>
</div>
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-gray-100 text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:bg-gray-800 dark:text-gray-300">
<tr>
<th className="w-6 border border-gray-200 px-1 py-1 dark:border-gray-700" />
<th className="min-w-[150px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
{labels.column}
</th>
<th className="min-w-[110px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
{labels.alias}
</th>
<th className="min-w-[90px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
{labels.table}
</th>
<th className="w-14 border border-gray-200 px-1 py-1 dark:border-gray-700">
{labels.output}
</th>
{grouped && (
<th className="min-w-[110px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
{labels.groupBy}
</th>
)}
<th className="w-[86px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
{labels.sortType}
</th>
<th className="w-[70px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
{labels.sortOrder}
</th>
{Array.from({ length: orCount }).map((_, i) => (
<th
key={i}
className="min-w-[130px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700"
>
{i === 0 ? labels.filter : labels.or}
</th>
))}
<th className="w-8 border border-gray-200 px-1 py-1 dark:border-gray-700" />
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td
className="border border-gray-200 px-2 py-6 text-center text-xs text-gray-400 dark:border-gray-700"
colSpan={(grouped ? 9 : 8) + orCount}
>
{labels.empty}
</td>
</tr>
)}
{rows.map((row) => {
const isAgg = grouped && isAggregateMode(row.groupMode)
const isExpression = row.expression !== undefined
return (
<tr
key={row.id}
className={
row.output ? 'bg-white dark:bg-gray-900' : 'bg-gray-50/70 dark:bg-gray-800/40'
}
>
<td className="border border-gray-200 px-0.5 text-center dark:border-gray-700">
<div className="flex flex-col items-center leading-none">
<button
type="button"
className="text-[8px] text-gray-300 hover:text-blue-500"
onClick={() => onMoveRow(row.id, -1)}
>
</button>
<FaGripVertical className="text-[8px] text-gray-300" />
<button
type="button"
className="text-[8px] text-gray-300 hover:text-blue-500"
onClick={() => onMoveRow(row.id, 1)}
>
</button>
</div>
</td>
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
{isExpression ? (
<input
className={`${cellCls} font-mono text-[11px] text-indigo-700 dark:text-indigo-300`}
placeholder={labels.expressionPlaceholder}
value={row.expression ?? ''}
title={row.expression}
onChange={(e) => onUpdateRow(row.id, { expression: e.target.value })}
/>
) : (
<select
className={cellCls}
value={row.columnName}
onChange={(e) => onUpdateRow(row.id, { columnName: e.target.value })}
>
<option value=""></option>
{columnsOf(row.sourceId).map((c) => (
<option key={c.columnName} value={c.columnName}>
{c.columnName}
</option>
))}
</select>
)}
</td>
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
<input
className={cellCls}
placeholder={row.columnName}
value={row.alias}
onChange={(e) => onUpdateRow(row.id, { alias: e.target.value })}
/>
</td>
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
{isExpression ? (
<span className="px-1 text-[10px] italic text-gray-400">SQL</span>
) : (
<select
className={cellCls}
value={row.sourceId}
onChange={(e) =>
onUpdateRow(row.id, { sourceId: e.target.value, columnName: '' })
}
>
{sources.map((s) => (
<option key={s.id} value={s.id}>
{s.alias || s.objectName}
</option>
))}
</select>
)}
</td>
<td className="border border-gray-200 text-center dark:border-gray-700">
<input
type="checkbox"
className="h-3.5 w-3.5"
checked={row.output}
disabled={row.groupMode === 'Where'}
onChange={(e) => onUpdateRow(row.id, { output: e.target.checked })}
/>
</td>
{grouped && (
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
<select
className={cellCls}
value={row.groupMode}
disabled={isExpression}
onChange={(e) => {
const mode = e.target.value as GroupMode
onUpdateRow(row.id, {
groupMode: mode,
...(mode === 'Where' ? { output: false } : {}),
})
}}
>
{GROUP_MODES.map((m) => (
<option key={m} value={m}>
{GROUP_MODE_LABEL[m]}
</option>
))}
</select>
</td>
)}
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
<select
className={cellCls}
value={row.sortType}
onChange={(e) => {
const sortType = e.target.value as '' | 'ASC' | 'DESC'
onUpdateRow(row.id, {
sortType,
sortOrder: sortType
? row.sortOrder || rows.filter((r) => r.sortType).length + 1
: 0,
})
}}
>
<option value=""></option>
<option value="ASC">Ascending</option>
<option value="DESC">Descending</option>
</select>
</td>
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
<input
type="number"
min={0}
className={cellCls}
value={row.sortOrder || ''}
disabled={!row.sortType}
onChange={(e) =>
onUpdateRow(row.id, { sortOrder: Number(e.target.value) || 0 })
}
/>
</td>
{Array.from({ length: orCount }).map((_, i) => (
<td
key={i}
className="relative border border-gray-200 px-0.5 dark:border-gray-700"
>
<div className="flex items-center gap-0.5">
<input
className={cellCls}
placeholder={i === 0 ? "> 100 · = 'A' · IS NULL" : ''}
value={row.filters[i] ?? ''}
onChange={(e) => onUpdateFilter(row.id, i, e.target.value)}
/>
<button
type="button"
className="shrink-0 text-[9px] text-gray-300 hover:text-blue-500"
title={labels.builder}
onClick={() =>
setBuilder(
builder?.rowId === row.id && builder.index === i
? null
: { rowId: row.id, index: i },
)
}
>
<FaMagic />
</button>
</div>
{builder?.rowId === row.id && builder.index === i && (
<FilterBuilder
labels={labels}
onApply={(text) => {
onUpdateFilter(row.id, i, text)
setBuilder(null)
}}
onClose={() => setBuilder(null)}
/>
)}
{isAgg && (row.filters[i] ?? '').trim() && (
<span className="pointer-events-none absolute -top-1 right-6 rounded bg-indigo-100 px-1 text-[8px] font-semibold text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300">
HAVING
</span>
)}
</td>
))}
<td className="border border-gray-200 text-center dark:border-gray-700">
<button
type="button"
className="text-[10px] text-gray-400 hover:text-red-500"
onClick={() => onRemoveRow(row.id)}
>
<FaTrash />
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
// ─── Filtre yardimcisi ────────────────────────────────────────────────────────
function FilterBuilder({
labels,
onApply,
onClose,
}: {
labels: CriteriaGridProps['labels']
onApply: (text: string) => void
onClose: () => void
}) {
const [operator, setOperator] = useState('=')
const [value, setValue] = useState('')
const [value2, setValue2] = useState('')
const [mode, setMode] = useState<'literal' | 'expression'>('literal')
const needsValue = operator !== 'IS NULL' && operator !== 'IS NOT NULL'
const build = () => {
if (!needsValue) return operator
const render = (v: string) => (mode === 'literal' ? quoteLiteral(v.trim()) : v.trim())
if (operator === 'BETWEEN') return `BETWEEN ${render(value)} AND ${render(value2)}`
if (operator === 'IN' || operator === 'NOT IN') {
if (mode === 'expression') return `${operator} (${value.trim()})`
const items = value
.split(',')
.map((v) => v.trim())
.filter(Boolean)
.map(quoteLiteral)
return `${operator} (${items.join(', ')})`
}
return `${operator} ${render(value)}`
}
return (
<div
className="absolute left-0 top-full z-50 mt-1 w-[230px] rounded-lg border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-800"
onClick={(e) => e.stopPropagation()}
>
<select
className="mb-1 w-full rounded border px-1 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value={operator}
onChange={(e) => setOperator(e.target.value)}
>
{OPERATORS.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</select>
{needsValue && (
<>
<input
className="mb-1 w-full rounded border px-1 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder={labels.builderValue}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
{operator === 'BETWEEN' && (
<input
className="mb-1 w-full rounded border px-1 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder={labels.builderValue}
value={value2}
onChange={(e) => setValue2(e.target.value)}
/>
)}
<select
className="mb-1 w-full rounded border px-1 py-1 text-[11px] dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value={mode}
onChange={(e) => setMode(e.target.value as 'literal' | 'expression')}
>
<option value="literal">{labels.builderLiteral}</option>
<option value="expression">{labels.builderExpression}</option>
</select>
</>
)}
<div className="flex items-center gap-1">
<button
type="button"
className="flex-1 rounded bg-blue-600 px-1 py-1 text-[11px] font-semibold text-white hover:bg-blue-700"
onClick={() => onApply(build())}
>
{labels.apply}
</button>
<button
type="button"
className="rounded px-2 py-1 text-[11px] text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={onClose}
>
</button>
</div>
</div>
)
}
export default CriteriaGrid